From 5db67cf731031539f2c7e51f35f796793470be87 Mon Sep 17 00:00:00 2001 From: Sharon Stratsianis Date: Sun, 11 Oct 2026 09:52:32 +1100 Subject: [PATCH 1/2] Created a draft of an extended profile service Prompt: Created 2 docs and pointed copilot to them, one for the best strategies for working with data in solidos and another to all the problems I found with the profile mutations. Told it copy the logic in profile-pane for mutations to solid-logic, extend the profile service and expose these methods. After that performed many cleanup tasks to simply/cleanup the code even further. Co-authored-by: GPT-5.6 Luna --- package-lock.json | 12 +- src/acl/accessControlSubjects.ts | 4 +- src/index.ts | 18 +- src/profile/profileLogic.ts | 54 +--- src/profile/profileMutationService.ts | 63 +++++ src/profile/sections/bio/mutations.ts | 45 +++ src/profile/sections/bio/types.ts | 18 ++ src/profile/sections/contactInfo/mutations.ts | 144 ++++++++++ .../sections/contactInfo/rdfBuilders.ts | 60 ++++ src/profile/sections/contactInfo/types.ts | 48 ++++ src/profile/sections/education/mutations.ts | 77 ++++++ src/profile/sections/education/types.ts | 23 ++ src/profile/sections/heading/mutations.ts | 243 +++++++++++++++++ src/profile/sections/heading/types.ts | 43 +++ src/profile/sections/languages/mutations.ts | 224 +++++++++++++++ .../sections/languages/normalization.ts | 19 ++ src/profile/sections/languages/selectors.ts | 141 ++++++++++ src/profile/sections/languages/types.ts | 17 ++ src/profile/sections/projects/mutations.ts | 187 +++++++++++++ src/profile/sections/projects/types.ts | 34 +++ src/profile/sections/resume/mutations.ts | 219 +++++++++++++++ src/profile/sections/resume/types.ts | 40 +++ src/profile/sections/shared/idNodeFactory.ts | 10 + src/profile/sections/shared/mutationErrors.ts | 7 + .../sections/shared/profileVocabularies.ts | 9 + .../sections/shared/projectCommunityNodes.ts | 59 ++++ src/profile/sections/shared/rdfList.ts | 43 +++ .../sections/shared/rdfMutationHelpers.ts | 103 +++++++ src/profile/sections/shared/text.ts | 7 + src/profile/sections/shared/types.ts | 27 ++ src/profile/sections/skills/mutations.ts | 120 ++++++++ src/profile/sections/skills/types.ts | 16 ++ src/profile/sections/social/helpers.ts | 43 +++ src/profile/sections/social/mutations.ts | 256 ++++++++++++++++++ src/profile/sections/social/selectors.ts | 42 +++ src/profile/sections/social/types.ts | 31 +++ src/profile/texts.ts | 36 +++ src/types.ts | 33 ++- src/util/containerLogic.ts | 16 +- src/util/utils.ts | 21 ++ 40 files changed, 2559 insertions(+), 53 deletions(-) create mode 100644 src/profile/profileMutationService.ts create mode 100644 src/profile/sections/bio/mutations.ts create mode 100644 src/profile/sections/bio/types.ts create mode 100644 src/profile/sections/contactInfo/mutations.ts create mode 100644 src/profile/sections/contactInfo/rdfBuilders.ts create mode 100644 src/profile/sections/contactInfo/types.ts create mode 100644 src/profile/sections/education/mutations.ts create mode 100644 src/profile/sections/education/types.ts create mode 100644 src/profile/sections/heading/mutations.ts create mode 100644 src/profile/sections/heading/types.ts create mode 100644 src/profile/sections/languages/mutations.ts create mode 100644 src/profile/sections/languages/normalization.ts create mode 100644 src/profile/sections/languages/selectors.ts create mode 100644 src/profile/sections/languages/types.ts create mode 100644 src/profile/sections/projects/mutations.ts create mode 100644 src/profile/sections/projects/types.ts create mode 100644 src/profile/sections/resume/mutations.ts create mode 100644 src/profile/sections/resume/types.ts create mode 100644 src/profile/sections/shared/idNodeFactory.ts create mode 100644 src/profile/sections/shared/mutationErrors.ts create mode 100644 src/profile/sections/shared/profileVocabularies.ts create mode 100644 src/profile/sections/shared/projectCommunityNodes.ts create mode 100644 src/profile/sections/shared/rdfList.ts create mode 100644 src/profile/sections/shared/rdfMutationHelpers.ts create mode 100644 src/profile/sections/shared/text.ts create mode 100644 src/profile/sections/shared/types.ts create mode 100644 src/profile/sections/skills/mutations.ts create mode 100644 src/profile/sections/skills/types.ts create mode 100644 src/profile/sections/social/helpers.ts create mode 100644 src/profile/sections/social/mutations.ts create mode 100644 src/profile/sections/social/selectors.ts create mode 100644 src/profile/sections/social/types.ts create mode 100644 src/profile/texts.ts diff --git a/package-lock.json b/package-lock.json index 0cb3c5aa..443f5d78 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7949,9 +7949,9 @@ "license": "MIT" }, "node_modules/solidos-toolkit": { - "version": "0.0.0-dev.998a984ddf8fda587eedb0c8125cb952ac28d191", - "resolved": "https://registry.npmjs.org/solidos-toolkit/-/solidos-toolkit-0.0.0-dev.998a984ddf8fda587eedb0c8125cb952ac28d191.tgz", - "integrity": "sha512-m2b3sZkKbjV5X/J8N25h3UIbsiQARdnXCGs1AeGqFiueoq8cWm+pdOyPpqiVuqRnnA5osmKG2wovUcBBYumL0Q==", + "version": "1.0.0-dev.c11da40967c5d278451118142650415d688d8d4c", + "resolved": "https://registry.npmjs.org/solidos-toolkit/-/solidos-toolkit-1.0.0-dev.c11da40967c5d278451118142650415d688d8d4c.tgz", + "integrity": "sha512-i5FOuvpBBbXfYpRKBv4ygdccO5EUUpfo2rp1HoOspG+AOKMVeFqizuPokl+RZX6AVn3AulqRfnlo+NUEWkidEA==", "dev": true, "dependencies": { "@babel/core": "^7.29.0", @@ -7970,10 +7970,10 @@ "vite-plugin-lit-css": "^3.0.0" }, "peerDependencies": { - "pane-registry": "^3.1.1 || ^3.1.2-0 || ^5.0.0 || ^5.0.0-0", + "pane-registry": "^3.1.1 || ^3.1.2-0 || >=5.0.2-0 <6.0.0", "rdflib": "^2.3.6", - "solid-logic": "^4.0.7 || ^4.0.8-0 || ^6.0.0 || ^6.0.0-0", - "solid-ui": "^3.1.2 || ^3.1.3-0 || ^5.0.0 || ^5.0.0-0", + "solid-logic": "^4.0.7 || ^4.0.8-0 || >=6.0.1-0 <7.0.0", + "solid-ui": "^3.1.2 || ^3.1.3-0 || >=5.0.1-0 <6.0.0", "vite": "^8.0.0", "vitest": "^4.0.0" }, diff --git a/src/acl/accessControlSubjects.ts b/src/acl/accessControlSubjects.ts index ee5671d4..65788ead 100644 --- a/src/acl/accessControlSubjects.ts +++ b/src/acl/accessControlSubjects.ts @@ -23,7 +23,7 @@ function isHttpUrl (value: string): boolean { return value.startsWith('http://') || value.startsWith('https://') } -function isBareOriginUrl (value: string): boolean { +export function isOriginUrl (value: string): boolean { try { const parsed = new URL(value) return (parsed.protocol === 'http:' || parsed.protocol === 'https:') && @@ -57,7 +57,7 @@ export async function classifyAccessControlSubject (store: LiveStore, principle: const subject = sym(principle) let types = store.findTypeURIs(subject) - if (isBareOriginUrl(principle)) { + if (isOriginUrl(principle)) { return { kind: 'origin', subjectValue: normalizeOriginUrl(principle) } } diff --git a/src/index.ts b/src/index.ts index 8cefa819..bb0d3619 100644 --- a/src/index.ts +++ b/src/index.ts @@ -14,10 +14,26 @@ export { reloadOnIdentityReplaced } from './authSession/identityState' export { getSuggestedIssuers } from './issuer/issuerLogic' export { createTypeIndexLogic } from './typeIndex/typeIndexLogic' export { createDirectoryLogic, DEFAULT_DIRECTORY_CATALOG_URL, DEFAULT_DIRECTORY_SOURCES } from './directory/directoryLogic' +export { createProfileMutationService } from './profile/profileMutationService' +export type { BioMutationPlan, BioRow } from './profile/sections/bio/types' +export type { ContactMutationPlan, ContactPointRow, ContactAddressRow } from './profile/sections/contactInfo/types' +export type { EducationMutationPlan, EducationRow } from './profile/sections/education/types' +export type { HeadingMutationPlan, ProfileBasicRow } from './profile/sections/heading/types' +export type { LanguageMutationPlan } from './profile/sections/languages/mutations' +export type { LanguageRow } from './profile/sections/languages/types' +export type { ProjectMutationPlan, ProjectRow } from './profile/sections/projects/types' +export type { ResumeMutationPlan } from './profile/sections/resume/mutations' +export type { ResumeRow } from './profile/sections/resume/types' +export type { SkillMutationPlan } from './profile/sections/skills/mutations' +export type { SkillRow } from './profile/sections/skills/types' +export type { SocialMutationPlan, SocialRow } from './profile/sections/social/types' +export type { MutationOps, RowStatus, RdfStatement } from './profile/sections/shared/types' +export { isOriginUrl } from './acl/accessControlSubjects' export type { AccessControlSubjectKind, AccessControlSubject } from './acl/accessControlSubjects' export type { ACLContext, AccessMode, AccessSubject, Authorization, PatchPlan } from './types' -export type { AppDetails, SolidNamespace, AuthenticationContext, SolidLogic, ChatLogic, DirectoryLogic, DirectoryEntry, DirectorySearchOptions, DirectoryRelationshipLabel, DirectorySource } from './types' +export type { AppDetails, SolidNamespace, AuthenticationContext, SolidLogic, ProfileLogic, ChatLogic, DirectoryLogic, DirectoryEntry, DirectorySearchOptions, DirectoryRelationshipLabel, DirectorySource, ProfileMutationService } from './types' export { UnauthorizedError, CrossOriginForbiddenError, SameOriginForbiddenError, NotFoundError, FetchError, NotEditableError, WebOperationError } from './logic/CustomError' +export { documentDirectoryUri, isAbsoluteHttpUri } from './util/utils' export { solidLogicSingleton, // solidLogicSingleton is exported entirely because it is used in solid-panes diff --git a/src/profile/profileLogic.ts b/src/profile/profileLogic.ts index 009dfda8..ae01ea89 100644 --- a/src/profile/profileLogic.ts +++ b/src/profile/profileLogic.ts @@ -2,64 +2,33 @@ import { literal, NamedNode, st, sym } from 'rdflib' import { ACL_LINK } from '../acl/aclLogic' import { CrossOriginForbiddenError, FetchError, NotEditableError, SameOriginForbiddenError, UnauthorizedError, WebOperationError } from '../logic/CustomError' import * as debug from '../util/debug' -import { ns as namespace } from '../util/ns' +import { ns } from '../util/ns' import { privateTypeIndexDocument, publicTypeIndexDocument } from '../typeIndex/typeIndexDocuments' import { publicTypeIndexAclDocument } from '../typeIndex/typeIndexAclDocuments' import { preferencesFileDocument } from './profileDocuments' import { ownerOnlyContainerAclDocument } from './profileAclDocuments' import { createContainerLogic } from '../util/containerLogic' -import { differentOrigin, suggestPreferencesFile } from '../util/utils' +import { differentOrigin, documentDirectoryUri, suggestPreferencesFile } from '../util/utils' +import { isMissingError } from '../resource/resourceHttp' import { ProfileLogic } from '../types' +import { createProfileMutationService } from './profileMutationService' export function createProfileLogic(store, authn, utilityLogic): ProfileLogic { - const ns = namespace const containerLogic = createContainerLogic(store) + const profileMutationService = createProfileMutationService(store) const loadPreferencesInFlight = new Map>() const cachedPreferencesFileByWebId = new Map() - function isAbsoluteHttpUri(uri: string | null | undefined): boolean { - return !!uri && (uri.startsWith('https://') || uri.startsWith('http://')) - } - - function docDirUri(node: NamedNode): string | null { - const doc = node.doc() - const dir = doc.dir() - if (dir?.uri && isAbsoluteHttpUri(dir.uri)) return dir.uri - const docUri = doc.uri - if (!docUri || !isAbsoluteHttpUri(docUri)) return null - const withoutFragment = docUri.split('#')[0] - const lastSlash = withoutFragment.lastIndexOf('/') - if (lastSlash === -1) return null - return withoutFragment.slice(0, lastSlash + 1) - } - function suggestTypeIndexInPreferences(preferencesFile: NamedNode, filename: string): NamedNode { - const dirUri = docDirUri(preferencesFile) + const dirUri = documentDirectoryUri(preferencesFile) if (!dirUri) throw new Error(`Cannot derive directory for preferences file ${preferencesFile.uri}`) return sym(dirUri + filename) } - function isNotFoundError(err: any): boolean { - if (err?.response?.status === 404) return true - const text = `${err?.message || err || ''}` - return text.includes('404') || text.includes('Not Found') - } - - async function ensureContainerExists(containerUri: string): Promise { - const containerNode = sym(containerUri) - try { - await store.fetcher.load(containerNode) - return - } catch (err) { - if (!isNotFoundError(err)) throw err - } - await containerLogic.createContainer(containerUri) - } - async function ensureOwnerOnlyAclForSettings(user: NamedNode, preferencesFile: NamedNode): Promise { - const dirUri = docDirUri(preferencesFile) + const dirUri = documentDirectoryUri(preferencesFile) if (!dirUri) throw new Error(`Cannot derive settings directory from ${preferencesFile.uri}`) - await ensureContainerExists(dirUri) + await containerLogic.ensureContainer(dirUri) const containerNode = sym(dirUri) let aclDocUri: string | undefined @@ -67,7 +36,7 @@ export function createProfileLogic(store, authn, utilityLogic): ProfileLogic { await store.fetcher.load(containerNode) aclDocUri = store.any(containerNode, ACL_LINK)?.value } catch (err) { - if (!isNotFoundError(err)) throw err + if (!isMissingError(err)) throw err } if (!aclDocUri) { // Fallback for servers/tests where rel=acl is not exposed in mocked headers. @@ -78,7 +47,7 @@ export function createProfileLogic(store, authn, utilityLogic): ProfileLogic { await store.fetcher.load(aclDoc) return } catch (err) { - if (!isNotFoundError(err)) throw err + if (!isMissingError(err)) throw err } await store.fetcher.webOperation('PUT', aclDoc.uri, { @@ -96,7 +65,7 @@ export function createProfileLogic(store, authn, utilityLogic): ProfileLogic { await store.fetcher.load(publicTypeIndex) aclDocUri = store.any(publicTypeIndex, ACL_LINK)?.value } catch (err) { - if (!isNotFoundError(err)) throw err + if (!isMissingError(err)) throw err } if (!aclDocUri) { aclDocUri = `${publicTypeIndex.uri}.acl` @@ -336,6 +305,7 @@ export function createProfileLogic(store, authn, utilityLogic): ProfileLogic { } return { + ...profileMutationService, loadMe, getPodRoot, getMainInbox, diff --git a/src/profile/profileMutationService.ts b/src/profile/profileMutationService.ts new file mode 100644 index 00000000..485ab523 --- /dev/null +++ b/src/profile/profileMutationService.ts @@ -0,0 +1,63 @@ +import { NamedNode, LiveStore } from 'rdflib' +import { NotEditableError } from '../logic/CustomError' +import type { ProfileMutationService } from '../types' +import { processBioMutations } from './sections/bio/mutations' +import { processContactInfoMutations } from './sections/contactInfo/mutations' +import { processEducationMutations } from './sections/education/mutations' +import { processHeadingMutations } from './sections/heading/mutations' +import { processLanguageMutations } from './sections/languages/mutations' +import { processProjectsMutations } from './sections/projects/mutations' +import { processResumeMutations } from './sections/resume/mutations' +import { processSkillsMutations } from './sections/skills/mutations' +import { processSocialMutations } from './sections/social/mutations' +import type { HeadingMutationPlan } from './sections/heading/types' +import type { BioMutationPlan } from './sections/bio/types' +import type { ContactMutationPlan } from './sections/contactInfo/types' +import type { EducationMutationPlan } from './sections/education/types' +import type { LanguageMutationPlan } from './sections/languages/mutations' +import type { LanguageRow } from './sections/languages/types' +import type { ProjectMutationPlan } from './sections/projects/types' +import type { ResumeMutationPlan } from './sections/resume/mutations' +import type { SkillMutationPlan } from './sections/skills/mutations' +import type { SocialMutationPlan, SocialRow } from './sections/social/types' + +export function createProfileMutationService (store: LiveStore): ProfileMutationService { + async function prepareProfileDocument (doc: NamedNode): Promise { + await store.fetcher.load(doc, { force: true }) + if (!store.updater.editable(doc)) { + throw new NotEditableError(`Cannot edit profile document ${doc.value}`) + } + } + + async function runProfileSectionMutation( + subject: NamedNode, + mutation: (store: LiveStore, subject: NamedNode, ...args: Args) => Promise, + ...args: Args + ): Promise { + await prepareProfileDocument(subject.doc()) + await mutation(store, subject, ...args) + } + + return { + processHeadingMutations: (subject: NamedNode, plan: HeadingMutationPlan) => + runProfileSectionMutation(subject, processHeadingMutations, plan), + processContactInfoMutations: (subject: NamedNode, plan: ContactMutationPlan) => + runProfileSectionMutation(subject, processContactInfoMutations, plan), + processBioMutations: (subject: NamedNode, plan: BioMutationPlan) => + runProfileSectionMutation(subject, processBioMutations, plan), + processEducationMutations: (subject: NamedNode, plan: EducationMutationPlan) => + runProfileSectionMutation(subject, processEducationMutations, plan), + processLanguageMutations: (subject: NamedNode, plan: LanguageMutationPlan, orderedRows?: LanguageRow[]) => + runProfileSectionMutation(subject, processLanguageMutations, plan, orderedRows), + processProjectsMutations: (subject: NamedNode, plan: ProjectMutationPlan) => + runProfileSectionMutation(subject, processProjectsMutations, plan), + processResumeMutations: (subject: NamedNode, plan: ResumeMutationPlan) => + runProfileSectionMutation(subject, processResumeMutations, plan), + processSkillsMutations: (subject: NamedNode, plan: SkillMutationPlan) => + runProfileSectionMutation(subject, processSkillsMutations, plan), + processSocialMutations: (subject: NamedNode, plan: SocialMutationPlan, orderedRows?: SocialRow[]) => + runProfileSectionMutation(subject, processSocialMutations, plan, orderedRows) + } +} + +export type { ProfileMutationService } diff --git a/src/profile/sections/bio/mutations.ts b/src/profile/sections/bio/mutations.ts new file mode 100644 index 00000000..3ab907cc --- /dev/null +++ b/src/profile/sections/bio/mutations.ts @@ -0,0 +1,45 @@ +import { LiveStore, NamedNode } from 'rdflib' +import { ns } from '../../../util/ns' +import { BioMutationPlan, BioRow } from './types' +import { MutationOps } from '../shared/types' +import { replaceLiteralPredicateStatements, runUpdateTransport } from '../shared/rdfMutationHelpers' +import { logAndThrowMutationError } from '../shared/mutationErrors' +import { bioUpdatesFailedMessageText, updaterUnsupportedStoreErrorMessageText } from '../../texts' + +async function mutateBioEntry(store: LiveStore, subject: NamedNode, bioOps: MutationOps) { + const doc = subject.doc() + const deletions: any[] = [] + const insertions: any[] = [] + + const applyBasics = (bio: BioRow, clearAll = false) => { + const data = clearAll + ? { + description: '', + } + : bio + + replaceLiteralPredicateStatements(store, subject, ns.vcard('note'), doc, deletions, insertions, data.description) + } + + const removeBio = bioOps.remove[0] + const updateBio = bioOps.update[0] + const createBio = bioOps.create[0] + + const selectedBio = updateBio || createBio || removeBio + if (selectedBio) { + applyBasics(selectedBio, Boolean(removeBio && !updateBio && !createBio)) + } + + await runUpdateTransport(store, doc, deletions, insertions, { + unsupportedMessage: updaterUnsupportedStoreErrorMessageText, + failureMessage: bioUpdatesFailedMessageText, + }) +} + +export async function processBioMutations(store: LiveStore, subject: NamedNode, mutationPlan: BioMutationPlan) { + try { + await mutateBioEntry(store, subject, mutationPlan) + } catch (error) { + logAndThrowMutationError(error) + } +} diff --git a/src/profile/sections/bio/types.ts b/src/profile/sections/bio/types.ts new file mode 100644 index 00000000..12a4f3a5 --- /dev/null +++ b/src/profile/sections/bio/types.ts @@ -0,0 +1,18 @@ +import { Node } from 'rdflib' +import { MutationOps } from '../shared/types' +import { RowStatus } from '../shared/types' + +export interface BioFields { + description?: string +} + +export interface BioDetails extends BioFields { + entryNode: Node +} + +export interface BioRow extends BioFields { + entryNode: string + status: RowStatus +} + +export type BioMutationPlan = MutationOps diff --git a/src/profile/sections/contactInfo/mutations.ts b/src/profile/sections/contactInfo/mutations.ts new file mode 100644 index 00000000..45ad6759 --- /dev/null +++ b/src/profile/sections/contactInfo/mutations.ts @@ -0,0 +1,144 @@ +import { LiveStore, NamedNode, Node } from 'rdflib' +import { ns } from '../../../util/ns' +import { ContactAddressRow, ContactMutationPlan, ContactPointRow } from './types' +import { MutationOps } from '../shared/types' +import { collectLinkedNodeStatements, collectNodeStatements, findExistingNode, runUpdateTransport } from '../shared/rdfMutationHelpers' +import { logAndThrowMutationError } from '../shared/mutationErrors' +import { createIdNode } from '../shared/idNodeFactory' +import { contactInfoUpdatesFailedMessageText, updaterUnsupportedStoreErrorMessageText } from '../../texts' +import { buildAddressStatements, buildEmailStatements, buildPhoneStatements } from './rdfBuilders' + +async function mutatePhoneEntries(store: LiveStore, subject: NamedNode, phoneOps: MutationOps) { + const doc = subject.doc() + const existingPhoneNodes = store.each(subject, ns.vcard('hasTelephone'), null, doc) as Node[] + const deletions: any[] = [] + const insertions: any[] = [] + + phoneOps.remove.forEach((phone) => { + if (!phone.entryNode) return + const existingNode = findExistingNode(existingPhoneNodes, phone.entryNode) + if (existingNode) { + const linkedPhoneStatements = collectLinkedNodeStatements(store, subject, ns.vcard('hasTelephone'), doc) + const matchingLinkStatement = linkedPhoneStatements.linkStatements.find((statement) => statement.object?.value === existingNode.value) + if (matchingLinkStatement) { + deletions.push(matchingLinkStatement) + } + deletions.push(...collectNodeStatements(store, existingNode, doc)) + } + }) + + phoneOps.update.forEach((phone) => { + if (!phone.entryNode) return + const existingNode = findExistingNode(existingPhoneNodes, phone.entryNode) + if (!existingNode) { + insertions.push(...buildPhoneStatements(subject, doc, createIdNode(doc), phone)) + return + } + const linkedPhoneStatements = collectLinkedNodeStatements(store, subject, ns.vcard('hasTelephone'), doc) + const matchingLinkStatement = linkedPhoneStatements.linkStatements.find((statement) => statement.object?.value === existingNode.value) + if (matchingLinkStatement) { + deletions.push(matchingLinkStatement) + } + deletions.push(...collectNodeStatements(store, existingNode, doc)) + insertions.push(...buildPhoneStatements(subject, doc, existingNode, phone)) + }) + + phoneOps.create.forEach((phone) => { + insertions.push(...buildPhoneStatements(subject, doc, createIdNode(doc), phone)) + }) + + await runUpdateTransport(store, doc, deletions, insertions, { + unsupportedMessage: updaterUnsupportedStoreErrorMessageText, + failureMessage: contactInfoUpdatesFailedMessageText, + }) +} + +async function mutateEmailEntries(store: LiveStore, subject: NamedNode, emailOps: MutationOps) { + const doc = subject.doc() + const existingEmailNodes = store.each(subject, ns.vcard('hasEmail'), null, doc) as Node[] + const deletions: any[] = [] + const insertions: any[] = [] + + emailOps.remove.forEach((email) => { + if (!email.entryNode) return + const existingNode = findExistingNode(existingEmailNodes, email.entryNode) + if (existingNode) { + const linkedEmailStatements = collectLinkedNodeStatements(store, subject, ns.vcard('hasEmail'), doc) + const matchingLinkStatement = linkedEmailStatements.linkStatements.find((statement) => statement.object?.value === existingNode.value) + if (matchingLinkStatement) { + deletions.push(matchingLinkStatement) + } + deletions.push(...collectNodeStatements(store, existingNode, doc)) + } + }) + + emailOps.update.forEach((email) => { + if (!email.entryNode) return + const existingNode = findExistingNode(existingEmailNodes, email.entryNode) + if (!existingNode) { + insertions.push(...buildEmailStatements(subject, doc, createIdNode(doc), email)) + return + } + deletions.push(...collectNodeStatements(store, existingNode, doc)) + insertions.push(...buildEmailStatements(subject, doc, existingNode, email)) + }) + + emailOps.create.forEach((email) => { + insertions.push(...buildEmailStatements(subject, doc, createIdNode(doc), email)) + }) + + await runUpdateTransport(store, doc, deletions, insertions, { + unsupportedMessage: updaterUnsupportedStoreErrorMessageText, + failureMessage: contactInfoUpdatesFailedMessageText, + }) +} + +async function mutateAddressEntries(store: LiveStore, subject: NamedNode, addressOps: MutationOps) { + const doc = subject.doc() + const existingAddressNodes = store.each(subject, ns.vcard('hasAddress'), null, doc) as Node[] + const deletions: any[] = [] + const insertions: any[] = [] + + addressOps.remove.forEach((address) => { + if (!address.entryNode) return + const existingNode = findExistingNode(existingAddressNodes, address.entryNode) + if (existingNode) { + const linkedAddressStatements = collectLinkedNodeStatements(store, subject, ns.vcard('hasAddress'), doc) + const matchingLinkStatement = linkedAddressStatements.linkStatements.find((statement) => statement.object?.value === existingNode.value) + if (matchingLinkStatement) { + deletions.push(matchingLinkStatement) + } + deletions.push(...collectNodeStatements(store, existingNode, doc)) + } + }) + + addressOps.update.forEach((address) => { + if (!address.entryNode) return + const existingNode = findExistingNode(existingAddressNodes, address.entryNode) + if (!existingNode) { + insertions.push(...buildAddressStatements(subject, doc, createIdNode(doc), address)) + return + } + deletions.push(...collectNodeStatements(store, existingNode, doc)) + insertions.push(...buildAddressStatements(subject, doc, existingNode, address)) + }) + + addressOps.create.forEach((address) => { + insertions.push(...buildAddressStatements(subject, doc, createIdNode(doc), address)) + }) + + await runUpdateTransport(store, doc, deletions, insertions, { + unsupportedMessage: updaterUnsupportedStoreErrorMessageText, + failureMessage: contactInfoUpdatesFailedMessageText, + }) +} + +export async function processContactInfoMutations(store: LiveStore, subject: NamedNode, mutationPlan: ContactMutationPlan) { + try { + await mutatePhoneEntries(store, subject, mutationPlan.phoneOps) + await mutateEmailEntries(store, subject, mutationPlan.emailOps) + await mutateAddressEntries(store, subject, mutationPlan.addressOps) + } catch (error) { + logAndThrowMutationError(error) + } +} diff --git a/src/profile/sections/contactInfo/rdfBuilders.ts b/src/profile/sections/contactInfo/rdfBuilders.ts new file mode 100644 index 00000000..888ceb88 --- /dev/null +++ b/src/profile/sections/contactInfo/rdfBuilders.ts @@ -0,0 +1,60 @@ +import { literal, NamedNode, Node, st, sym } from 'rdflib' +import { ns } from '../../../util/ns' +import type { ContactAddressRow, ContactPointRow } from './types' +import type { RdfStatement } from '../shared/types' + +export function buildPhoneStatements ( + subject: NamedNode, + doc: NamedNode, + node: Node, + phone: ContactPointRow +): RdfStatement[] { + const value = phone.value.startsWith('tel:') ? phone.value : `tel:${phone.value}` + const statements = [ + st(subject, ns.vcard('hasTelephone'), node as any, doc), + st(node as any, ns.vcard('value'), sym(value), doc) + ] + + if (phone.type) { + statements.push(st(node as any, ns.rdf('type'), ns.vcard(phone.type), doc)) + } + + return statements +} + +export function buildEmailStatements ( + subject: NamedNode, + doc: NamedNode, + node: Node, + email: ContactPointRow +): RdfStatement[] { + const value = email.value.startsWith('mailto:') ? email.value : `mailto:${email.value}` + const statements = [ + st(subject, ns.vcard('hasEmail'), node as any, doc), + st(node as any, ns.vcard('value'), sym(value), doc) + ] + + if (email.type) { + statements.push(st(node as any, ns.rdf('type'), ns.vcard(email.type), doc)) + } + + return statements +} + +export function buildAddressStatements ( + subject: NamedNode, + doc: NamedNode, + node: Node, + address: ContactAddressRow +): RdfStatement[] { + const statements = [st(subject, ns.vcard('hasAddress'), node as any, doc)] + + if (address.type) statements.push(st(node as any, ns.rdf('type'), ns.vcard(address.type), doc)) + if (address.streetAddress) statements.push(st(node as any, ns.vcard('street-address'), literal(address.streetAddress), doc)) + if (address.locality) statements.push(st(node as any, ns.vcard('locality'), literal(address.locality), doc)) + if (address.region) statements.push(st(node as any, ns.vcard('region'), literal(address.region), doc)) + if (address.postalCode) statements.push(st(node as any, ns.vcard('postal-code'), literal(address.postalCode), doc)) + if (address.countryName) statements.push(st(node as any, ns.vcard('country-name'), literal(address.countryName), doc)) + + return statements +} diff --git a/src/profile/sections/contactInfo/types.ts b/src/profile/sections/contactInfo/types.ts new file mode 100644 index 00000000..1a28a533 --- /dev/null +++ b/src/profile/sections/contactInfo/types.ts @@ -0,0 +1,48 @@ +import { Node } from 'rdflib' +import { MutationOps, RowStatus } from '../shared/types' +/* using Node instead of NamedNode because the data could +also be a blank node because it's coming from user-edited profile data */ +export interface PointDetails { + entryNode: Node, + type: Node, + valueNode: Node +} +export interface AddressDetails { + entryNode: Node, + type?: Node, + streetAddress?: string, + locality?: string, + region?: string, + postalCode?: string, + countryName?: string +} + +export interface ContactInfo { + emails: PointDetails[], + phones: PointDetails[], + addresses: AddressDetails[] +} + +export type ContactPointRow = { + value: string + type: string + entryNode: string + status: RowStatus +} + +export type ContactAddressRow = { + streetAddress?: string, + locality?: string, + region?: string, + postalCode?: string, + countryName?: string + type: string + entryNode: string + status: RowStatus +} + +export type ContactMutationPlan = { + phoneOps: MutationOps + emailOps: MutationOps + addressOps: MutationOps +} diff --git a/src/profile/sections/education/mutations.ts b/src/profile/sections/education/mutations.ts new file mode 100644 index 00000000..2fdebb9c --- /dev/null +++ b/src/profile/sections/education/mutations.ts @@ -0,0 +1,77 @@ +import { LiveStore, NamedNode, Node, st, literal } from 'rdflib' +import { ns } from '../../../util/ns' +import { EducationMutationPlan, EducationRow } from './types' +import { MutationOps } from '../shared/types' +import { collectNodeStatements, findExistingNode, runUpdateTransport } from '../shared/rdfMutationHelpers' +import { logAndThrowMutationError } from '../shared/mutationErrors' +import { educationUpdatesFailedMessageText, updaterUnsupportedStoreErrorMessageText } from '../../texts' + +const educationMembershipType = ns.schema('EducationalOccupationalCredential') + +function isEducationMembership(store: LiveStore, node: Node, doc: NamedNode): boolean { + return store.holds(node as any, ns.rdf('type'), educationMembershipType, doc as any) +} + +function buildEducationStatements( + subject: NamedNode, + doc: NamedNode, + node: Node, + education: EducationRow, + includeMembershipLink = true +) { + const inserts = includeMembershipLink ? [st(node as any, ns.org('member'), subject, doc)] : [] + inserts.push(st(node as any, ns.rdf('type'), educationMembershipType, doc)) + + if (education.school) inserts.push(st(node as any, ns.schema('name'), literal(education.school), doc)) + if (education.degree) inserts.push(st(node as any, ns.schema('educationalCredentialAwarded'), literal(education.degree), doc)) + if (education.location) inserts.push(st(node as any, ns.schema('location'), literal(education.location), doc)) + if (education.startDate) inserts.push(st(node as any, ns.schema('startDate'), literal(education.startDate), doc)) + if (education.endDate) inserts.push(st(node as any, ns.schema('endDate'), literal(education.endDate), doc)) + if (education.description) inserts.push(st(node as any, ns.schema('description'), literal(education.description), doc)) + + return inserts +} + +async function mutateEducationEntries(store: LiveStore, subject: NamedNode, educationOps: MutationOps) { + const doc = subject.doc() + const existingEducationNodes = (store.each(null, ns.org('member'), subject, doc) as Node[]) + .filter((node) => isEducationMembership(store, node, doc)) + const deletions: any[] = [] + const insertions: any[] = [] + + educationOps.remove.forEach((education) => { + if (!education.entryNode) return + const existingNode = findExistingNode(existingEducationNodes, education.entryNode) + if (existingNode) { + deletions.push(...collectNodeStatements(store, existingNode, doc)) + } + }) + + educationOps.update.forEach((education) => { + if (!education.entryNode) return + const existingNode = findExistingNode(existingEducationNodes, education.entryNode) + if (!existingNode) { + insertions.push(...buildEducationStatements(subject, doc, store.bnode(), education)) + return + } + deletions.push(...collectNodeStatements(store, existingNode, doc)) + insertions.push(...buildEducationStatements(subject, doc, existingNode, education)) + }) + + educationOps.create.forEach((education) => { + insertions.push(...buildEducationStatements(subject, doc, store.bnode(), education)) + }) + + await runUpdateTransport(store, doc, deletions, insertions, { + unsupportedMessage: updaterUnsupportedStoreErrorMessageText, + failureMessage: educationUpdatesFailedMessageText, + }) +} + +export async function processEducationMutations(store: LiveStore, subject: NamedNode, mutationPlan: EducationMutationPlan) { + try { + await mutateEducationEntries(store, subject, mutationPlan) + } catch (error) { + logAndThrowMutationError(error) + } +} diff --git a/src/profile/sections/education/types.ts b/src/profile/sections/education/types.ts new file mode 100644 index 00000000..f24d4d6a --- /dev/null +++ b/src/profile/sections/education/types.ts @@ -0,0 +1,23 @@ +import { Node } from 'rdflib' +import { MutationOps } from '../shared/types' +import { RowStatus } from '../shared/types' + +export interface EducationFields { + school: string + degree?: string + location?: string + startDate?: string + endDate?: string + description?: string +} + +export interface EducationDetails extends EducationFields { + entryNode: Node +} + +export interface EducationRow extends EducationFields { + entryNode: string + status: RowStatus +} + +export type EducationMutationPlan = MutationOps diff --git a/src/profile/sections/heading/mutations.ts b/src/profile/sections/heading/mutations.ts new file mode 100644 index 00000000..7d31625b --- /dev/null +++ b/src/profile/sections/heading/mutations.ts @@ -0,0 +1,243 @@ +import { LiveStore, NamedNode, Node, literal, sym } from 'rdflib' +import { ns } from '../../../util/ns' +import { ProfileBasicRow, HeadingMutationPlan } from './types' +import { MutationOps } from '../shared/types' +import { collectLinkedNodeStatements, collectNodeStatements, findExistingNode, replaceLiteralPredicateStatements, replacePredicateStatements, runUpdateTransport } from '../shared/rdfMutationHelpers' +import { logAndThrowMutationError } from '../shared/mutationErrors' +import { createIdNode } from '../shared/idNodeFactory' +import { ContactAddressRow, ContactPointRow } from '../contactInfo/types' +import { buildAddressStatements, buildEmailStatements, buildPhoneStatements } from '../contactInfo/rdfBuilders' +import { headingUpdatesFailedMessageText, updaterUnsupportedStoreErrorMessageText } from '../../texts' +import { trimText } from '../shared/text' + +function splitPronouns(value?: string): { subjectPronoun?: string, objectPronoun?: string } { + const normalized = trimText(value) + if (!normalized) return {} + + const [subjectPronounRaw, objectPronounRaw] = normalized.split('/').map((part) => part.trim()) + const subjectPronoun = subjectPronounRaw || undefined + const objectPronoun = objectPronounRaw || undefined + + return { subjectPronoun, objectPronoun } +} + +function findOpWithExistingEntry(ops: T[], existingNodes: Node[]): T | undefined { + return ops.find((op) => Boolean(op.entryNode) && Boolean(findExistingNode(existingNodes, op.entryNode))) +} + +function findCreateOp(ops: T[]): T | undefined { + return ops.find((op) => !op.entryNode) +} + +async function mutatePhoneEntry(store: LiveStore, subject: NamedNode, phoneOps: MutationOps) { + const doc = subject.doc() + const existingPhoneNodes = store.each(subject, ns.vcard('hasTelephone'), null, doc) as Node[] + const deletions: any[] = [] + const insertions: any[] = [] + + const removePhone = findOpWithExistingEntry(phoneOps.remove, existingPhoneNodes) + const updatePhone = findOpWithExistingEntry(phoneOps.update, existingPhoneNodes) + const createPhone = findCreateOp(phoneOps.create) || phoneOps.create[0] + + if (removePhone?.entryNode) { + const existingNode = findExistingNode(existingPhoneNodes, removePhone.entryNode) + if (existingNode) { + const linkedPhoneStatements = collectLinkedNodeStatements(store, subject, ns.vcard('hasTelephone'), doc) + const matchingLinkStatement = linkedPhoneStatements.linkStatements.find((statement) => statement.object?.value === existingNode.value) + if (matchingLinkStatement) { + deletions.push(matchingLinkStatement) + } + deletions.push(...collectNodeStatements(store, existingNode, doc)) + } + } + + if (updatePhone) { + const existingNode = findExistingNode(existingPhoneNodes, updatePhone.entryNode) + if (existingNode) { + deletions.push(...collectNodeStatements(store, existingNode, doc)) + insertions.push(...buildPhoneStatements(subject, doc, existingNode, updatePhone)) + } + } + + if (createPhone) { + insertions.push(...buildPhoneStatements(subject, doc, createIdNode(doc), createPhone)) + } + + await runUpdateTransport(store, doc, deletions, insertions, { + unsupportedMessage: updaterUnsupportedStoreErrorMessageText, + failureMessage: headingUpdatesFailedMessageText, + }) +} + +async function mutateEmailEntry(store: LiveStore, subject: NamedNode, emailOps: MutationOps) { + const doc = subject.doc() + const existingEmailNodes = store.each(subject, ns.vcard('hasEmail'), null, doc) as Node[] + const deletions: any[] = [] + const insertions: any[] = [] + + const removeEmail = findOpWithExistingEntry(emailOps.remove, existingEmailNodes) + const updateEmail = findOpWithExistingEntry(emailOps.update, existingEmailNodes) + const createEmail = findCreateOp(emailOps.create) || emailOps.create[0] + + if (removeEmail?.entryNode) { + const existingNode = findExistingNode(existingEmailNodes, removeEmail.entryNode) + if (existingNode) { + const linkedEmailStatements = collectLinkedNodeStatements(store, subject, ns.vcard('hasEmail'), doc) + const matchingLinkStatement = linkedEmailStatements.linkStatements.find((statement) => statement.object?.value === existingNode.value) + if (matchingLinkStatement) { + deletions.push(matchingLinkStatement) + } + deletions.push(...collectNodeStatements(store, existingNode, doc)) + } + } + + if (updateEmail) { + const existingNode = findExistingNode(existingEmailNodes, updateEmail.entryNode) + if (existingNode) { + deletions.push(...collectNodeStatements(store, existingNode, doc)) + insertions.push(...buildEmailStatements(subject, doc, existingNode, updateEmail)) + } + } + + if (createEmail) { + insertions.push(...buildEmailStatements(subject, doc, createIdNode(doc), createEmail)) + } + + await runUpdateTransport(store, doc, deletions, insertions, { + unsupportedMessage: updaterUnsupportedStoreErrorMessageText, + failureMessage: headingUpdatesFailedMessageText, + }) +} + +async function mutateAddressEntry(store: LiveStore, subject: NamedNode, addressOps: MutationOps) { + const doc = subject.doc() + const existingAddressNodes = store.each(subject, ns.vcard('hasAddress'), null, doc) as Node[] + const deletions: any[] = [] + const insertions: any[] = [] + + const removeAddress = findOpWithExistingEntry(addressOps.remove, existingAddressNodes) + const updateAddress = findOpWithExistingEntry(addressOps.update, existingAddressNodes) + const createAddress = findCreateOp(addressOps.create) || addressOps.create[0] + + if (removeAddress?.entryNode) { + const existingNode = findExistingNode(existingAddressNodes, removeAddress.entryNode) + if (existingNode) { + const linkedAddressStatements = collectLinkedNodeStatements(store, subject, ns.vcard('hasAddress'), doc) + const matchingLinkStatement = linkedAddressStatements.linkStatements.find((statement) => statement.object?.value === existingNode.value) + if (matchingLinkStatement) { + deletions.push(matchingLinkStatement) + } + deletions.push(...collectNodeStatements(store, existingNode, doc)) + } + } + + if (updateAddress) { + const existingNode = findExistingNode(existingAddressNodes, updateAddress.entryNode) + if (existingNode) { + deletions.push(...collectNodeStatements(store, existingNode, doc)) + insertions.push(...buildAddressStatements(subject, doc, existingNode, updateAddress)) + } + } + + if (createAddress) { + insertions.push(...buildAddressStatements(subject, doc, createIdNode(doc), createAddress)) + } + + await runUpdateTransport(store, doc, deletions, insertions, { + unsupportedMessage: updaterUnsupportedStoreErrorMessageText, + failureMessage: headingUpdatesFailedMessageText, + }) +} + +async function mutateBasicProfileEntry(store: LiveStore, subject: NamedNode, basicOps: MutationOps) { + const doc = subject.doc() + const deletions: any[] = [] + const insertions: any[] = [] + + const replacePhotoField = (value?: string) => { + const normalized = trimText(value) + const nextObject = !normalized + ? null + : (normalized.startsWith('http://') || normalized.startsWith('https://')) + ? sym(normalized) + : literal(normalized) + replacePredicateStatements(store, subject, ns.vcard('hasPhoto'), doc, deletions, insertions, nextObject) + } + + const replacePronounFields = (value?: string) => { + const { subjectPronoun, objectPronoun } = splitPronouns(value) + + replacePredicateStatements( + store, + subject, + ns.solid('preferredSubjectPronoun'), + doc, + deletions, + insertions, + subjectPronoun ? literal(subjectPronoun) : null + ) + replacePredicateStatements( + store, + subject, + ns.solid('preferredObjectPronoun'), + doc, + deletions, + insertions, + objectPronoun ? literal(objectPronoun) : null + ) + replacePredicateStatements( + store, + subject, + ns.solid('preferredRelativePronoun'), + doc, + deletions, + insertions, + null + ) + } + + const applyBasics = (basic: ProfileBasicRow, clearAll = false) => { + const data = clearAll + ? { + name: '', + nickname: '', + pronouns: '', + dateOfBirth: '', + imageSrc: '' + } + : basic + + replaceLiteralPredicateStatements(store, subject, ns.vcard('fn'), doc, deletions, insertions, data.name) + // Keep foaf:nick in sync where available in existing data. + replaceLiteralPredicateStatements(store, subject, ns.foaf('nick'), doc, deletions, insertions, data.nickname) + replaceLiteralPredicateStatements(store, subject, ns.vcard('nickname'), doc, deletions, insertions, data.nickname) + replaceLiteralPredicateStatements(store, subject, ns.vcard('bday'), doc, deletions, insertions, data.dateOfBirth) + replacePronounFields(data.pronouns) + replacePhotoField(data.imageSrc) + } + + const removeBasic = basicOps.remove[0] + const updateBasic = basicOps.update[0] + const createBasic = basicOps.create[0] + + const selectedBasic = updateBasic || createBasic || removeBasic + if (selectedBasic) { + applyBasics(selectedBasic, Boolean(removeBasic && !updateBasic && !createBasic)) + } + + await runUpdateTransport(store, doc, deletions, insertions, { + unsupportedMessage: updaterUnsupportedStoreErrorMessageText, + failureMessage: headingUpdatesFailedMessageText, + }) +} + +export async function processHeadingMutations(store: LiveStore, subject: NamedNode, mutationPlan: HeadingMutationPlan) { + try { + await mutateBasicProfileEntry(store, subject, mutationPlan.basicOps) + await mutatePhoneEntry(store, subject, mutationPlan.phoneOps) + await mutateEmailEntry(store, subject, mutationPlan.emailOps) + await mutateAddressEntry(store, subject, mutationPlan.addressOps) + } catch (error) { + logAndThrowMutationError(error) + } +} diff --git a/src/profile/sections/heading/types.ts b/src/profile/sections/heading/types.ts new file mode 100644 index 00000000..c5a82f6a --- /dev/null +++ b/src/profile/sections/heading/types.ts @@ -0,0 +1,43 @@ +import { Node } from 'rdflib' +import { RowStatus } from '../shared/types' +import { MutationOps } from '../shared/types' +import type { AddressDetails, ContactAddressRow, ContactPointRow, PointDetails } from '../contactInfo/types' + +export interface ProfilePresentation { + name: string; + nickname?: string; + imageSrc?: string; + location?: string; + pronouns?: string; + dateOfBirth?: string; + jobTitle?: string; + primaryPhone?: PointDetails; + primaryEmail?: PointDetails; + primaryAddress?: AddressDetails; +} + +export interface ProfileDetails extends ProfilePresentation { + entryNode: Node +} + +export interface ProfileRow extends ProfilePresentation { + entryNode: string + status: RowStatus +} + +export type ProfileBasicFields = Omit< + ProfilePresentation, + 'jobTitle' | 'primaryPhone' | 'primaryEmail' | 'primaryAddress' +> + +export type ProfileBasicRow = ProfileBasicFields & { + entryNode: string + status: RowStatus +} + +export type HeadingMutationPlan = { + basicOps: MutationOps + phoneOps: MutationOps + emailOps: MutationOps + addressOps: MutationOps +} diff --git a/src/profile/sections/languages/mutations.ts b/src/profile/sections/languages/mutations.ts new file mode 100644 index 00000000..74a6ac80 --- /dev/null +++ b/src/profile/sections/languages/mutations.ts @@ -0,0 +1,224 @@ +import { Collection, LiveStore, NamedNode, st, sym, literal } from 'rdflib' +import { ns } from '../../../util/ns' +import { LanguageRow } from './types' +import { MutationOps, RdfResourceNode, RdfStatement } from '../shared/types' +import { presentLanguages } from './selectors' +import { expandRdfList } from '../shared/rdfList' +import { createIdNode } from '../shared/idNodeFactory' +import { findExistingNode, isNamedOrBlankNode, rdfNodeKey, runUpdateTransport } from '../shared/rdfMutationHelpers' +import { logAndThrowMutationError } from '../shared/mutationErrors' +import { languageUpdatesFailedMessageText, languageUpdatesUnsupportedMessageText } from '../../texts' +import { trimText } from '../shared/text' +import { ianaLanguageVocabularyUri, languageCodeFromPublicId } from './normalization' + +export type LanguageMutationPlan = MutationOps +// Language entries are serialized as an ordered rdf:List of id nodes, where +// each id node points to an IANA URI via solid:publicId. +function rowsFromOrderedInput(orderedRows: LanguageRow[]): LanguageRow[] { + return (orderedRows || []) + .filter((row) => row.status !== 'deleted') + .filter((row) => Boolean(trimText(row.publicId) || trimText(row.name) || trimText(row.entryNode))) + .map((row) => ({ + ...row, + name: trimText(row.name), + publicId: trimText(row.publicId), + proficiency: trimText(row.proficiency), + entryNode: trimText(row.entryNode) + })) +} + +function buildLanguageStatements( + store: LiveStore, + subject: NamedNode, + doc: NamedNode, + rows: LanguageRow[], + existingLanguageNodes: RdfResourceNode[] +) { + const languageRows = rows + .map((row) => ({ + name: trimText(row.name), + publicId: languageCodeFromPublicId(row.publicId), + entryNode: trimText(row.entryNode) + })) + .filter((row) => Boolean(row.publicId)) + + if (languageRows.length === 0) { + return { statements: [] as RdfStatement[], entryNodes: [] as RdfResourceNode[] } + } + + const entryNodes = languageRows.map((row) => { + const existingNode = row.entryNode + ? findExistingNode(existingLanguageNodes, row.entryNode) + : null + + if (isNamedOrBlankNode(existingNode)) { + return existingNode + } + + return createIdNode(doc) + }) + const statements: RdfStatement[] = [] + + statements.push(st(subject, ns.schema('knowsLanguage'), new Collection(entryNodes), doc)) + + entryNodes.forEach((entryNode, index) => { + const publicIdNode = sym(`${ianaLanguageVocabularyUri}${languageRows[index].publicId}`) + statements.push(st(entryNode, ns.solid('publicId'), publicIdNode, doc)) + const existingNameStatement = store.holds(publicIdNode, ns.schema('name'), literal(languageRows[index].name, 'en'), doc) + if (languageRows[index].name && !existingNameStatement) { + statements.push(st(publicIdNode, ns.schema('name'), literal(languageRows[index].name, 'en'), doc)) + } + }) + + return { statements, entryNodes } +} + +function collectListChainNodes(store: LiveStore, listHead: NamedNode, doc: NamedNode): NamedNode[] { + const visited = new Set() + const nodes: NamedNode[] = [] + let current: NamedNode | null = listHead + + while (current) { + const key = `${current.termType}:${current.value}` + if (visited.has(key)) break + visited.add(key) + nodes.push(current) + + const rest = store.any(current as any, ns.rdf('rest'), null, doc) + if (!rest || (rest.termType === 'NamedNode' && rest.value === ns.rdf('nil').value)) { + break + } + current = (rest.termType === 'NamedNode' || rest.termType === 'BlankNode') ? rest as NamedNode : null + } + + return nodes +} + +function mergeLanguageOps(existingRows: LanguageRow[], languageOps: MutationOps): LanguageRow[] { + const byEntryNode = new Map() + existingRows.forEach((row) => { + byEntryNode.set(row.entryNode, row) + }) + + languageOps.remove.forEach((row) => { + if (!row.entryNode) return + byEntryNode.delete(row.entryNode) + }) + + languageOps.update.forEach((row) => { + if (!row.entryNode) return + if (!byEntryNode.has(row.entryNode)) return + byEntryNode.set(row.entryNode, row) + }) + + languageOps.create.forEach((row) => { + if (!trimText(row.publicId)) return + byEntryNode.set(`new:${row.name}:${Math.random()}`, { + ...row, + publicId: trimText(row.publicId), + proficiency: trimText(row.proficiency) + }) + }) + + const dedupedByLanguage = new Map() + Array.from(byEntryNode.values()).forEach((row) => { + const publicId = trimText(row.publicId) + if (!publicId) return + if (!dedupedByLanguage.has(publicId)) { + dedupedByLanguage.set(publicId, { + ...row, + publicId, + proficiency: trimText(row.proficiency) + }) + } + }) + + return Array.from(dedupedByLanguage.values()) +} + +async function mutateLanguageEntries( + store: LiveStore, + subject: NamedNode, + languageOps: MutationOps, + orderedRows?: LanguageRow[] +) { + const doc = subject.doc() + const existingRows = presentLanguages(subject, store).map((detail) => ({ + name: trimText(detail.name), + publicId: trimText(detail.publicId), + proficiency: trimText(detail.proficiency), + entryNode: detail.entryNode.value, + status: 'existing' as const + })) + const nextRows = orderedRows && orderedRows.length + ? rowsFromOrderedInput(orderedRows) + : mergeLanguageOps(existingRows, languageOps) + + const listObjects = store.each(subject, ns.schema('knowsLanguage'), null, doc) + const existingListHeads = listObjects.filter((node): node is NamedNode => node.termType === 'BlankNode' || node.termType === 'NamedNode') + + const existingListNodes = Array.from(new Map( + existingListHeads + .flatMap((node) => collectListChainNodes(store, node, doc)) + .map((node) => [rdfNodeKey(node), node]) + ).values()) + + const existingLanguageNodes: RdfResourceNode[] = Array.from(new Set( + listObjects + .flatMap((node) => expandRdfList(store, node)) + .filter(isNamedOrBlankNode) + .map(rdfNodeKey) + )).map((key) => { + const [termType, ...rest] = key.split(':') + const value = rest.join(':') + return termType === 'BlankNode' ? store.bnode(value) : store.sym(value) + }) + + const { + statements: insertions, + entryNodes: nextEntryNodes + } = buildLanguageStatements(store, subject, doc, nextRows, existingLanguageNodes) + + const retainedEntryNodeKeys = new Set(nextEntryNodes.map(rdfNodeKey)) + const retainedLanguageNodes = existingLanguageNodes.filter((node) => retainedEntryNodeKeys.has(rdfNodeKey(node))) + const removedLanguageNodes = existingLanguageNodes.filter((node) => !retainedEntryNodeKeys.has(rdfNodeKey(node))) + + const removedPublicIdNodes = Array.from(new Map( + removedLanguageNodes + .map((node) => store.any(node as NamedNode, ns.solid('publicId'), null, doc)) + .filter((node): node is NamedNode => Boolean(node && node.termType === 'NamedNode')) + .map((node) => [rdfNodeKey(node), node]) + ).values()) + + const deletions: RdfStatement[] = store.statementsMatching(subject, ns.schema('knowsLanguage'), null, doc) + existingListNodes.forEach((node) => { + deletions.push(...store.statementsMatching(node, null, null, doc)) + }) + removedLanguageNodes.forEach((node) => { + deletions.push(...store.statementsMatching(node as NamedNode, null, null, doc)) + }) + retainedLanguageNodes.forEach((node) => { + deletions.push(...store.statementsMatching(node as NamedNode, ns.solid('publicId'), null, doc)) + }) + removedPublicIdNodes.forEach((node) => { + deletions.push(...store.statementsMatching(node as NamedNode, ns.schema('name'), null, doc)) + }) + + await runUpdateTransport(store, doc, deletions, insertions, { + unsupportedMessage: languageUpdatesUnsupportedMessageText, + failureMessage: languageUpdatesFailedMessageText, + }) +} + +export async function processLanguageMutations( + store: LiveStore, + subject: NamedNode, + mutationPlan: LanguageMutationPlan, + orderedRows?: LanguageRow[] +) { + try { + await mutateLanguageEntries(store, subject, mutationPlan, orderedRows) + } catch (error) { + logAndThrowMutationError(error) + } +} diff --git a/src/profile/sections/languages/normalization.ts b/src/profile/sections/languages/normalization.ts new file mode 100644 index 00000000..5f37a50d --- /dev/null +++ b/src/profile/sections/languages/normalization.ts @@ -0,0 +1,19 @@ +import { profileVocabularies } from '../shared/profileVocabularies' +import { trimText } from '../shared/text' + +export const ianaLanguageVocabularyUri = profileVocabularies.ianaLanguage + +export function normalizeLanguageCode(value: string | null | undefined): string { + return trimText(value).toLowerCase() +} + +export function languageCodeFromPublicId(value: string | null | undefined): string { + const normalized = normalizeLanguageCode(value) + return normalized.startsWith(ianaLanguageVocabularyUri) + ? normalized.slice(ianaLanguageVocabularyUri.length) + : normalized +} + +export function isIanaLanguageUri(value: string | null | undefined): boolean { + return trimText(value).startsWith(ianaLanguageVocabularyUri) +} diff --git a/src/profile/sections/languages/selectors.ts b/src/profile/sections/languages/selectors.ts new file mode 100644 index 00000000..0572fd31 --- /dev/null +++ b/src/profile/sections/languages/selectors.ts @@ -0,0 +1,141 @@ +import { Literal, NamedNode, Store, Node, LiveStore, sym } from 'rdflib' +import { ns } from '../../../util/ns' +import { LanguageDetails } from './types' +import { expandRdfList, isRdfListNode } from '../shared/rdfList' +import { normalizeWhitespace } from '../shared/text' +import { + ianaLanguageVocabularyUri, + isIanaLanguageUri, + languageCodeFromPublicId, + normalizeLanguageCode +} from './normalization' + +function languageNameFromCode(code: string): string { + const normalized = normalizeLanguageCode(code) + if (!normalized) return '' + + try { + const DisplayNamesCtor = (Intl as any)?.DisplayNames + if (typeof DisplayNamesCtor === 'function') { + const displayNames = new DisplayNamesCtor(['en'], { type: 'language' }) + const localized = displayNames.of(normalized) + if (localized) return localized + } + } catch { + // Fallback below. + } + + return normalized +} + +function languageNameFromPublicId(publicIdValue: string): string { + return languageNameFromCode(languageCodeFromPublicId(publicIdValue)) +} + +function preferredLiteralValue( + store: Store, + subject: NamedNode, + predicate: NamedNode, + fallbackToAnyLiteral = false +): string { + const literals = store + .statementsMatching(subject, predicate) + .map((statement) => statement.object) + .filter((node): node is Literal => node.termType === 'Literal') + const englishLiteral = literals.find((literal) => literal.lang.toLowerCase().startsWith('en')) + return englishLiteral?.value || (fallbackToAnyLiteral ? literals[0]?.value || '' : '') +} + +function languageNameFromIanaNode(store: Store, node: NamedNode): string { + const schemaName = preferredLiteralValue(store, node, ns.schema('name'), true) + if (schemaName) return normalizeWhitespace(schemaName) + + const localLabel = preferredLiteralValue(store, node, ns.rdfs('label')) + if (localLabel) return normalizeWhitespace(localLabel) + + if (isIanaLanguageUri(node.value)) { + return languageNameFromPublicId(node.value) + } + + return '' +} + +function toStoredPublicIdValue(lan: Node, publicIdNode: Node | undefined): string | undefined { + if (publicIdNode?.termType === 'NamedNode') { + return publicIdNode.value + } + + if (publicIdNode?.termType === 'Literal') { + const code = normalizeLanguageCode(publicIdNode.value) + return code || undefined + } + + if (lan.termType === 'NamedNode' && isIanaLanguageUri(lan.value)) { + return lan.value + } + + return undefined +} + +function languageNameFromLanguageNode(store: Store, lan: Node, publicIdNode: Node | undefined): string { + if (publicIdNode?.termType === 'NamedNode') { + const nodeName = languageNameFromIanaNode(store, publicIdNode as NamedNode) + if (nodeName) return nodeName + return languageNameFromPublicId(publicIdNode.value) + } + + if (publicIdNode?.termType === 'Literal') { + const code = normalizeLanguageCode(publicIdNode.value) + if (!code) return '' + + const ianaNode = sym(`${ianaLanguageVocabularyUri}${code}`) + const nodeName = languageNameFromIanaNode(store, ianaNode) + if (nodeName) return nodeName + return languageNameFromCode(code) + } + + if (lan.termType === 'NamedNode') { + const nodeName = languageNameFromIanaNode(store, lan as NamedNode) + if (nodeName) return nodeName + } + + return '' +} + +export function languageAsText (store: Store, lan: Node):string { + if (lan.termType === 'Literal') return lan.value // Not normal but allow this + + if (lan.termType !== 'NamedNode' && lan.termType !== 'BlankNode') return '' + + const publicIdNode = store.any(lan as NamedNode, ns.solid('publicId')) || undefined + return languageNameFromLanguageNode(store, lan, publicIdNode) +} + +export function presentLanguages(subject: NamedNode, store: LiveStore): LanguageDetails[] { + const languageObjects = store.each(subject, ns.schema('knowsLanguage')) + const expandedLists = languageObjects + .filter((node) => isRdfListNode(store, node)) + .map((node) => expandRdfList(store, node)) + + const standaloneNodes = languageObjects.filter((node) => !isRdfListNode(store, node)) + const languageNodes = [...expandedLists.flat(), ...standaloneNodes] + + const details: LanguageDetails[] = languageNodes + .map((lan) => ({ + name: languageAsText(store, lan), + publicId: toStoredPublicIdValue(lan, store.any(lan as NamedNode, ns.solid('publicId')) || undefined), + proficiency: store.anyValue(lan as NamedNode, ns.schema('proficiencyLevel')) || undefined, + entryNode: lan + })) + .filter((item) => Boolean(item.name)) + + const dedupedByLanguage = new Map() + details.forEach((item) => { + const key = item.publicId || item.name + if (!dedupedByLanguage.has(key)) { + dedupedByLanguage.set(key, item) + } + }) + + return Array.from(dedupedByLanguage.values()) +} diff --git a/src/profile/sections/languages/types.ts b/src/profile/sections/languages/types.ts new file mode 100644 index 00000000..2e1a2532 --- /dev/null +++ b/src/profile/sections/languages/types.ts @@ -0,0 +1,17 @@ +import { Node } from 'rdflib' +import { RowStatus } from '../shared/types' + +export interface LanguageFields { + name: string + publicId?: string + proficiency?: string +} + +export interface LanguageDetails extends LanguageFields { + entryNode: Node +} + +export interface LanguageRow extends LanguageFields { + entryNode: string + status: RowStatus +} diff --git a/src/profile/sections/projects/mutations.ts b/src/profile/sections/projects/mutations.ts new file mode 100644 index 00000000..88df90d7 --- /dev/null +++ b/src/profile/sections/projects/mutations.ts @@ -0,0 +1,187 @@ +import { LiveStore, NamedNode, Node, st, sym } from 'rdflib' +import { ns } from '../../../util/ns' +import { ProjectMutationPlan, ProjectRow } from './types' +import { MutationOps, RdfStatement } from '../shared/types' +import { + projectUrlFromCommunityNode, + expandCommunityNodes +} from '../shared/projectCommunityNodes' +import { isRdfListNode } from '../shared/rdfList' +import { rdfNodeKey } from '../shared/rdfMutationHelpers' +import { runUpdateTransport } from '../shared/rdfMutationHelpers' +import { logAndThrowMutationError } from '../shared/mutationErrors' +import { projectUpdatesFailedMessageText, projectUpdatesUnsupportedMessageText } from '../../texts' +import { trimText } from '../shared/text' + +function parseProjectUrl(value: string | null | undefined): URL | null { + const text = trimText(value) + if (!text) return null + try { + return new URL(text) + } catch { + return null + } +} + +function toProjectUrlNode(project: ProjectRow): NamedNode | null { + const parsed = parseProjectUrl(project.url) + return parsed ? sym(parsed.href) : null +} + +function normalizeProjectUrlKey(value: string): string { + const parsed = parseProjectUrl(value) + if (!parsed) return trimText(value) + parsed.hash = '' + return parsed.href +} + +function collectListChainNodes(store: LiveStore, listHead: unknown, doc: NamedNode): Node[] { + if (!store.any(listHead as NamedNode, ns.rdf('first'), null, doc)) return [] + + const visited = new Set() + const nodes: Node[] = [] + let current = listHead as Node | null + + while (current) { + const key = rdfNodeKey(current) + if (visited.has(key)) break + visited.add(key) + nodes.push(current) + + const rest = store.any(current as NamedNode, ns.rdf('rest'), null, doc) as Node | null + if (!rest || (rest.termType === 'NamedNode' && rest.value === ns.rdf('nil').value)) { + break + } + current = rest + } + + return nodes +} + +function collectCommunityCleanupStatements(store: LiveStore, statement: RdfStatement, fallbackDoc: NamedNode): RdfStatement[] { + const doc = (statement.why as NamedNode | null) || fallbackDoc + const cleanup = [st(statement.subject, statement.predicate, statement.object, doc)] + const objectNode = statement.object + + if (isRdfListNode(store, objectNode, doc)) { + collectListChainNodes(store, objectNode, doc).forEach((listNode) => { + cleanup.push(...store.statementsMatching(listNode as NamedNode, null, null, doc)) + }) + return cleanup + } + + if (objectNode.termType === 'BlankNode') { + cleanup.push(...store.statementsMatching(objectNode as unknown as NamedNode, null, null, doc)) + } + + return cleanup +} + +async function mutateProjectEntries(store: LiveStore, subject: NamedNode, projectOps: MutationOps) { + const doc = subject.doc() + type DocBatch = { + doc: NamedNode + cleanupStatements: RdfStatement[] + currentUrlsByKey: Map + } + + const existingLinks = store.statementsMatching(subject, ns.solid('community'), null, null) + const batchesByDoc = new Map() + const docKeyByUrl = new Map() + const touchedDocs = new Set() + + const getDocBatch = (targetDoc: NamedNode) => { + const existing = batchesByDoc.get(targetDoc.value) + if (existing) return existing + const next: DocBatch = { doc: targetDoc, cleanupStatements: [], currentUrlsByKey: new Map() } + batchesByDoc.set(targetDoc.value, next) + return next + } + + existingLinks.forEach((statement) => { + const targetDoc = (statement.why as NamedNode | null) || doc + const batch = getDocBatch(targetDoc) + batch.cleanupStatements.push(...collectCommunityCleanupStatements(store, statement as RdfStatement, doc)) + + const objectNode = statement.object + const communityNodes = expandCommunityNodes(store, objectNode as Node, targetDoc) + + communityNodes.forEach((communityNode) => { + const url = projectUrlFromCommunityNode(communityNode, store) + const key = normalizeProjectUrlKey(url) + if (!key || docKeyByUrl.has(key)) return + docKeyByUrl.set(key, targetDoc.value) + batch.currentUrlsByKey.set(key, url) + }) + }) + + const ensureDocTouched = (docKey: string) => { + touchedDocs.add(docKey) + } + + const removeExistingUrl = (key: string) => { + const targetDocKey = docKeyByUrl.get(key) + if (!targetDocKey) return null + const batch = batchesByDoc.get(targetDocKey) + if (!batch) return null + batch.currentUrlsByKey.delete(key) + docKeyByUrl.delete(key) + ensureDocTouched(targetDocKey) + return batch.doc + } + + const addUrlToDoc = (targetDoc: NamedNode, url: string) => { + const key = normalizeProjectUrlKey(url) + if (!key || docKeyByUrl.has(key)) return + const batch = getDocBatch(targetDoc) + batch.currentUrlsByKey.set(key, url) + docKeyByUrl.set(key, targetDoc.value) + ensureDocTouched(targetDoc.value) + } + + projectOps.remove.forEach((project) => { + const entryKey = normalizeProjectUrlKey(project.entryNode || '') + const urlKey = normalizeProjectUrlKey(project.url) + if (entryKey) { + removeExistingUrl(entryKey) + return + } + if (urlKey) { + removeExistingUrl(urlKey) + } + }) + + projectOps.update.forEach((project) => { + const newLink = toProjectUrlNode(project) + const entryKey = normalizeProjectUrlKey(project.entryNode || '') + const urlKey = normalizeProjectUrlKey(project.url) + const removedDoc = (entryKey && removeExistingUrl(entryKey)) || (urlKey && removeExistingUrl(urlKey)) || doc + if (newLink) { + addUrlToDoc(removedDoc, newLink.value) + } + }) + + projectOps.create.forEach((project) => { + const newLink = toProjectUrlNode(project) + if (!newLink) return + addUrlToDoc(doc, newLink.value) + }) + + for (const docKey of touchedDocs) { + const batch = batchesByDoc.get(docKey) + if (!batch) continue + const insertions = Array.from(batch.currentUrlsByKey.values()).map((url) => st(subject, ns.solid('community'), sym(url), batch.doc)) + await runUpdateTransport(store, batch.doc, batch.cleanupStatements, insertions, { + unsupportedMessage: projectUpdatesUnsupportedMessageText, + failureMessage: projectUpdatesFailedMessageText, + }) + } +} + +export async function processProjectsMutations(store: LiveStore, subject: NamedNode, mutationPlan: ProjectMutationPlan) { + try { + await mutateProjectEntries(store, subject, mutationPlan) + } catch (err) { + logAndThrowMutationError(err) + } +} diff --git a/src/profile/sections/projects/types.ts b/src/profile/sections/projects/types.ts new file mode 100644 index 00000000..47d250cb --- /dev/null +++ b/src/profile/sections/projects/types.ts @@ -0,0 +1,34 @@ +import { Node } from 'rdflib' +import { MutationOps } from '../shared/types' +import { RowStatus } from '../shared/types' +import { ns } from '../../../util/ns' + +export const projectType = ns.schema('Project') + +export interface ProjectFields { + url: string + title?: string + name?: string + orgName?: string + imageUrl?: string + category?: 'project' | 'community' | 'unknown' +} + +export interface ProjectDetails extends ProjectFields { + entryNode: Node +} + +export interface ProjectRow extends ProjectFields { + entryNode: string + status: RowStatus +} + +export type ProjectInputRowProps = { + rows: ProjectRow[] + index: number + displayIndex: number + onDelete: () => void + onChange: () => void +} + +export type ProjectMutationPlan = MutationOps diff --git a/src/profile/sections/resume/mutations.ts b/src/profile/sections/resume/mutations.ts new file mode 100644 index 00000000..f2214a28 --- /dev/null +++ b/src/profile/sections/resume/mutations.ts @@ -0,0 +1,219 @@ +import { Literal, LiveStore, NamedNode, Node, st, literal, sym } from 'rdflib' +import { ns } from '../../../util/ns' +import { ResumeRow } from './types' +import { MutationOps } from '../shared/types' +import { collectLinkedNodeStatements, collectNodeStatements, findExistingNode, replacePredicateStatements, runUpdateTransport } from '../shared/rdfMutationHelpers' +import { logAndThrowMutationError } from '../shared/mutationErrors' +import { createIdNode } from '../shared/idNodeFactory' +import { trimText } from '../shared/text' +import { + resumeUpdateEntryNotFoundErrorMessageText, + resumeUpdatesFailedMessageText, + updaterUnsupportedStoreErrorMessageText +} from '../../texts' + +function asXsdDateLiteral(value?: string): Literal | null { + const normalizedValue = trimText(value) + if (!normalizedValue) return null + + const dateOnly = normalizedValue.slice(0, 10) + return literal(dateOnly, ns.xsd('date')) +} + +/* The following helper was generated by AI Model: GPT-5.3-Codex. */ +/* Prompt: write me a function to determine PastRole, CurrentRole, or FutureRole based on resume role date data. + The function should return a NamedNode with the correct solid namespace and role type local name. + The function should use the roleType field if it is set to one of those three values, + otherwise it should infer the role type based on the start and end dates and isCurrentRole field. */ +function membershipTypeForRole(resumeData: ResumeRow): NamedNode { + const normalizedRoleType = trimText(resumeData.roleType) + if (normalizedRoleType === 'PastRole' || normalizedRoleType === 'CurrentRole' || normalizedRoleType === 'FutureRole') { + return ns.solid(normalizedRoleType) + } + + if (resumeData.isCurrentRole) return ns.solid('CurrentRole') + + const todayIso = new Date().toISOString().slice(0, 10) + const startIso = resumeData.startDate?.value?.slice(0, 10) || '' + const endIso = resumeData.endDate?.value?.slice(0, 10) || '' + + if (startIso && startIso > todayIso) return ns.solid('FutureRole') + if (endIso && endIso < todayIso) return ns.solid('PastRole') + return ns.solid('FutureRole') +} + +function roleTypeNode(orgType: string): NamedNode | null { + const text = trimText(orgType) + if (!text) return null + + // These are membership role classes, not organization classes. + if (text === 'PastRole' || text === 'CurrentRole' || text === 'FutureRole') { + return null + } + + if (text.startsWith('http://') || text.startsWith('https://')) { + const local = text.split(/[#/]/).pop() + if (local === 'PastRole' || local === 'CurrentRole' || local === 'FutureRole') { + return null + } + return sym(text) + } + + const prefixed = text.match(/^([a-zA-Z][\w-]*):(.*)$/) + if (prefixed) { + const prefix = prefixed[1].toLowerCase() + const local = trimText(prefixed[2]) + if (!local) return null + if (local === 'PastRole' || local === 'CurrentRole' || local === 'FutureRole') { + return null + } + /* Organization types vary need to return the correct namespace */ + if (prefix === 'schema') return ns.schema(local) + if (prefix === 'vcard') return ns.vcard(local) + if (prefix === 'solid') return ns.solid(local) + if (prefix === 'org') return ns.org(local) + return null + } + + // For plain values like "Corporation", map to schema:Corporation. + return ns.schema(text) +} + +/* The following helper was generated by AI Model: GPT-5.3-Codex. + Prompt: 'do you see anything I can do differently in the mutation code so that it's not so verbose and possible reusable accross sections' */ +function buildOrganizationStatements(doc: NamedNode, membershipNode: Node, resumeData: ResumeRow) { + if (!resumeData.orgName && !resumeData.orgType && !resumeData.orgLocation && !resumeData.orgHomePage) { + return [] + } + + const organizationNode = createIdNode(doc) + const inserts: any[] = [ + st(membershipNode as any, ns.org('organization'), organizationNode, doc) + ] + + const organizationClassNode = roleTypeNode(resumeData.orgType || '') + if (organizationClassNode) { + inserts.push(st(organizationNode, ns.rdf('type'), organizationClassNode, doc)) + } + + inserts.push(st(organizationNode, ns.rdf('type'), ns.vcard('Organization'), doc)) + + if (resumeData.orgName) { + inserts.push(st(organizationNode, ns.schema('name'), literal(resumeData.orgName), doc)) + } + if (resumeData.orgLocation) { + inserts.push(st(organizationNode, ns.org('location'), literal(resumeData.orgLocation), doc)) + } + if (resumeData.orgHomePage) { + inserts.push(st(organizationNode, ns.schema('uri'), literal(resumeData.orgHomePage), doc)) + } + const orgPublicId = trimText(resumeData.orgPublicId) + if (orgPublicId) { + inserts.push(st(organizationNode, ns.solid('publicId'), sym(orgPublicId), doc)) + } + + return inserts +} + + +function buildResumeStatements( + subject: NamedNode, + doc: NamedNode, + node: Node, + resumeData: ResumeRow, + includeMembershipLink = true +) { + const inserts: any[] = [] + + if (includeMembershipLink) { + inserts.push(st(node as any, ns.org('member'), subject, doc)) + } + + inserts.push(st(node as any, ns.rdf('type'), membershipTypeForRole(resumeData), doc)) + + if (resumeData.title) { + inserts.push(st(node as any, ns.vcard('role'), literal(resumeData.title), doc)) + } + if (resumeData.startDate) { + const startDateLiteral = asXsdDateLiteral(resumeData.startDate.value) + if (startDateLiteral) { + inserts.push(st(node as any, ns.schema('startDate'), startDateLiteral as any, doc)) + } + } + if (resumeData.endDate && !resumeData.isCurrentRole) { + const endDateLiteral = asXsdDateLiteral(resumeData.endDate.value) + if (endDateLiteral) { + inserts.push(st(node as any, ns.schema('endDate'), endDateLiteral as any, doc)) + } + } + if (resumeData.description) { + inserts.push(st(node as any, ns.schema('description'), literal(resumeData.description), doc)) + } + + inserts.push(...buildOrganizationStatements(doc, node, resumeData)) + + return inserts +} + +async function mutateResumeEntries(store: LiveStore, subject: NamedNode, resumeOps: MutationOps) { + const doc = subject.doc() + const existingResumeNodes = store.each(null, ns.org('member'), subject, doc) as Node[] + const deletions: any[] = [] + const insertions: any[] = [] + + resumeOps.remove.forEach((resume) => { + if (!resume.entryNode) return + const existingNode = findExistingNode(existingResumeNodes, resume.entryNode) + if (existingNode) { + const linkedOrganizations = collectLinkedNodeStatements(store, existingNode, ns.org('organization'), doc) + deletions.push(...linkedOrganizations.linkedStatements) + deletions.push(...collectNodeStatements(store, existingNode, doc)) + } + }) + + resumeOps.update.forEach((resume) => { + if (!resume.entryNode) return + const existingNode = findExistingNode(existingResumeNodes, resume.entryNode) + if (!existingNode) { + throw new Error(resumeUpdateEntryNotFoundErrorMessageText) + } + + if ((existingNode as any).termType === 'BlankNode') { + // Blank node labels are not always stable for remote updates; migrate to a named node. + const migratedNode = createIdNode(doc) + insertions.push(...buildResumeStatements(subject, doc, migratedNode, resume, true)) + return + } + + // For updates on existing named memberships, keep the membership link and replace only mutable fields. + ;[ns.rdf('type'), ns.vcard('role'), ns.schema('startDate'), ns.schema('endDate'), ns.schema('description')] + .forEach((predicate) => { + replacePredicateStatements(store, existingNode as NamedNode, predicate, doc, deletions, insertions, null) + }) + + const linkedOrganizations = collectLinkedNodeStatements(store, existingNode, ns.org('organization'), doc) + deletions.push(...linkedOrganizations.linkedStatements) + deletions.push(...linkedOrganizations.linkStatements) + + insertions.push(...buildResumeStatements(subject, doc, existingNode, resume, false)) + }) + + resumeOps.create.forEach((resume) => { + insertions.push(...buildResumeStatements(subject, doc, createIdNode(doc), resume)) + }) + + await runUpdateTransport(store, doc, deletions, insertions, { + unsupportedMessage: updaterUnsupportedStoreErrorMessageText, + failureMessage: resumeUpdatesFailedMessageText, + }) +} + +export type ResumeMutationPlan = MutationOps + +export async function processResumeMutations(store: LiveStore, subject: NamedNode, mutationPlan: ResumeMutationPlan) { + try { + await mutateResumeEntries(store, subject, mutationPlan) + } catch (error) { + logAndThrowMutationError(error) + } +} diff --git a/src/profile/sections/resume/types.ts b/src/profile/sections/resume/types.ts new file mode 100644 index 00000000..6895c491 --- /dev/null +++ b/src/profile/sections/resume/types.ts @@ -0,0 +1,40 @@ +import { Literal, Node } from 'rdflib' +import { RowStatus } from '../shared/types' + +export interface ResumeDetails extends RoleDetails {} + +export interface RoleDetails extends Role { + entryNode: Node +} + +export interface ResumeRow extends Role { + entryNode: string + status: RowStatus +} + +export interface Role { + title: string, + roleType?: string, + startDate?: Literal, + endDate?: Literal, + isCurrentRole?: boolean, + orgName: string, + orgPublicId?: string, + orgType?: string, + orgLocation?: string, + orgHomePage?: string, + description?: string +} + +/* Presentation of CV data has changed do not need rolesbytype */ +export const typesOfRole = ['PastRole', 'CurrentRole', 'FutureRole'] + +export interface CVPresentation { + roles: RoleDetails[]; +} + +export interface RolesByType { + PastRole: RoleDetails[]; + CurrentRole: RoleDetails[]; + FutureRole: RoleDetails[]; +} diff --git a/src/profile/sections/shared/idNodeFactory.ts b/src/profile/sections/shared/idNodeFactory.ts new file mode 100644 index 00000000..2ff7ebe4 --- /dev/null +++ b/src/profile/sections/shared/idNodeFactory.ts @@ -0,0 +1,10 @@ +import { NamedNode, sym } from 'rdflib' + +let lastIssuedTimestamp = 0 + +export function createIdNode(doc: NamedNode): NamedNode { + const now = Date.now() + const timestampId = now > lastIssuedTimestamp ? now : lastIssuedTimestamp + 1 + lastIssuedTimestamp = timestampId + return sym(`${doc.uri}#id${timestampId}`) as NamedNode +} diff --git a/src/profile/sections/shared/mutationErrors.ts b/src/profile/sections/shared/mutationErrors.ts new file mode 100644 index 00000000..91e56e91 --- /dev/null +++ b/src/profile/sections/shared/mutationErrors.ts @@ -0,0 +1,7 @@ +import { error as debugError } from '../../../util/debug' + +export function logAndThrowMutationError(error: unknown): never { + const rootError = error instanceof Error ? error : new Error(String(error)) + debugError(rootError) + throw rootError +} diff --git a/src/profile/sections/shared/profileVocabularies.ts b/src/profile/sections/shared/profileVocabularies.ts new file mode 100644 index 00000000..1b942f14 --- /dev/null +++ b/src/profile/sections/shared/profileVocabularies.ts @@ -0,0 +1,9 @@ +/** + * Nonstandard vocabularies used by profile section data. + * Standard RDF vocabularies are available through `ns`. + */ +export const profileVocabularies = { + escoSkill: 'http://data.europa.eu/esco/skill/', + ianaLanguage: 'https://www.w3.org/ns/iana/language-code/', + socialMedia: 'https://solidos.github.io/profile-pane/src/ontology/socialMedia.ttl#' +} as const diff --git a/src/profile/sections/shared/projectCommunityNodes.ts b/src/profile/sections/shared/projectCommunityNodes.ts new file mode 100644 index 00000000..073aec05 --- /dev/null +++ b/src/profile/sections/shared/projectCommunityNodes.ts @@ -0,0 +1,59 @@ +import { LiveStore, NamedNode, Node, sym } from 'rdflib' +import { ns } from '../../../util/ns' +import { expandRdfList, isRdfListNode } from './rdfList' + +export function expandCommunityNodes(store: LiveStore, node: Node, doc?: NamedNode | null): Node[] { + return isRdfListNode(store, node, doc) ? expandRdfList(store, node) : [node] +} + +export function linkedProjectNodeFromCommunityNode(node: Node, store: LiveStore): NamedNode | null { + if (!node) return null + if (node.termType === 'NamedNode') return node as NamedNode + if (node.termType === 'Literal') { + try { + return sym(node.value) + } catch { + return null + } + } + if (node.termType !== 'BlankNode') return null + + const candidatePredicates = [ + ns.solid('publicId'), + ns.schema('url'), + ns.foaf('homepage'), + ns.rdfs('seeAlso'), + ns.schema('sameAs'), + ns.schema('mainEntityOfPage') + ] + + for (const predicate of candidatePredicates) { + const linked = store.any(node as NamedNode, predicate) as Node | null + if (!linked?.value) continue + + if (linked.termType === 'NamedNode') { + return linked as NamedNode + } + + if (linked.termType === 'Literal') { + try { + return sym(linked.value) + } catch { + // Keep trying the next predicate. + } + } + } + + const outgoing = store.statementsMatching(node as NamedNode, null, null, null) + const firstNamedNode = outgoing.find((statement) => statement.object.termType === 'NamedNode') + if (firstNamedNode?.object?.value) { + return firstNamedNode.object as NamedNode + } + + return null +} + +export function projectUrlFromCommunityNode(node: Node, store: LiveStore): string { + const linkedProjectNode = linkedProjectNodeFromCommunityNode(node, store) + return linkedProjectNode?.value || node.value || '' +} diff --git a/src/profile/sections/shared/rdfList.ts b/src/profile/sections/shared/rdfList.ts new file mode 100644 index 00000000..5a4b605b --- /dev/null +++ b/src/profile/sections/shared/rdfList.ts @@ -0,0 +1,43 @@ +import { NamedNode, Node, Store } from 'rdflib' +import { ns } from '../../../util/ns' +import { rdfNodeKey } from './rdfMutationHelpers' + +export function isRdfListNode(store: Store, node: unknown, doc?: NamedNode | null): boolean { + const candidate = node as Node & { elements?: Node[] } + const collectionElements = candidate?.elements + if (Array.isArray(collectionElements)) return true + if (candidate?.termType !== 'BlankNode' && candidate?.termType !== 'NamedNode') return false + return Boolean(store.any(candidate as NamedNode, ns.rdf('first'), null, doc || null)) +} + +export function expandRdfList(store: Store, node: Node): Node[] { + const visited = new Set() + + function inner(currentNode: Node): Node[] { + const key = rdfNodeKey(currentNode) + if (visited.has(key)) return [] + visited.add(key) + + const collectionElements = (currentNode as { termType?: string; elements?: Node[] }).elements + if (Array.isArray(collectionElements)) { + return collectionElements.flatMap((element) => inner(element)) + } + + const first = store.any(currentNode as NamedNode, ns.rdf('first')) + if (!first) return [currentNode] + + const items: Node[] = [] + let listNode: Node | null = currentNode + while (listNode) { + const listValue = store.any(listNode as NamedNode, ns.rdf('first')) as Node | null + if (listValue) items.push(...inner(listValue)) + const rest = store.any(listNode as NamedNode, ns.rdf('rest')) as Node | null + if (!rest || (rest.termType === 'NamedNode' && rest.value === ns.rdf('nil').value)) break + listNode = rest + } + + return items + } + + return inner(node) +} diff --git a/src/profile/sections/shared/rdfMutationHelpers.ts b/src/profile/sections/shared/rdfMutationHelpers.ts new file mode 100644 index 00000000..49aee72e --- /dev/null +++ b/src/profile/sections/shared/rdfMutationHelpers.ts @@ -0,0 +1,103 @@ +import { LiveStore, NamedNode, Node, st, literal } from 'rdflib' +import { RdfResourceNode, RdfStatement } from './types' +import { fallbackSaveUpdatesErrorMessageText, updaterUnsupportedStoreErrorMessageText } from '../../texts' +import { NotEditableError } from '../../../logic/CustomError' +import { trimText } from './text' + +type UpdateTransportOptions = { + unsupportedMessage?: string + failureMessage?: string +} + +export function uniqueStatements (statements: RdfStatement[]): RdfStatement[] { + return [...new Map(statements.map(statement => [statement.toNT(), statement])).values()] +} + +export function rdfNodeKey (node: Node): string { + return `${node.termType}:${node.value}` +} + +export function isNamedOrBlankNode (node: Node | null | undefined): node is RdfResourceNode { + return Boolean(node && (node.termType === 'NamedNode' || node.termType === 'BlankNode')) +} + +export function collectNodeStatements (store: LiveStore, node: Node, doc: NamedNode): RdfStatement[] { + return store.statementsMatching(node as any, null, null, doc) +} + +export function collectLinkedNodeStatements (store: LiveStore, sourceNode: Node, predicate: NamedNode, doc: NamedNode) { + const linkedNodes = store.each(sourceNode as any, predicate, null, doc) as Node[] + return { + linkedNodes, + linkedStatements: linkedNodes.flatMap(node => collectNodeStatements(store, node, doc)), + linkStatements: store.statementsMatching(sourceNode as any, predicate, null, doc) + } +} + +export function findExistingNode (nodes: Node[], entryNode: string): Node | undefined { + const normalized = entryNode.startsWith('_:') ? entryNode.slice(2) : entryNode + return nodes.find(node => node.value === entryNode || node.value === normalized || node.value === `_:${normalized}`) +} + +export function replacePredicateStatements (store: LiveStore, subject: NamedNode, predicate: NamedNode, doc: NamedNode, deletions: RdfStatement[], insertions: RdfStatement[], nextObject?: Node | null): void { + deletions.push(...store.statementsMatching(subject, predicate, null, doc)) + if (nextObject) insertions.push(st(subject, predicate, nextObject, doc)) +} + +export function replaceLiteralPredicateStatements ( + store: LiveStore, + subject: NamedNode, + predicate: NamedNode, + doc: NamedNode, + deletions: RdfStatement[], + insertions: RdfStatement[], + value?: string +): void { + const normalizedValue = trimText(value) + replacePredicateStatements( + store, + subject, + predicate, + doc, + deletions, + insertions, + normalizedValue ? literal(normalizedValue) : null + ) +} + +function updateFailureError ( + message: string | undefined, + response: unknown, + fallbackMessage: string | undefined +): Error { + if (response instanceof Error) return response + + const error = new Error(message || fallbackMessage || fallbackSaveUpdatesErrorMessageText) + if (response && typeof response === 'object') { + Object.assign(error, response) + } + return error +} + +export async function runUpdateTransport ( + store: LiveStore, + doc: NamedNode, + deletions: RdfStatement[], + insertions: RdfStatement[], + options: UpdateTransportOptions = {} +): Promise { + if (!store.updater || typeof store.updater.update !== 'function') throw new Error(options.unsupportedMessage || updaterUnsupportedStoreErrorMessageText) + if (!store.updater.editable(doc)) throw new NotEditableError(`Cannot edit profile document ${doc.value}`) + if (!deletions.length && !insertions.length) return + await new Promise((resolve, reject) => { + let settled = false + const done = (fn: () => void) => { if (!settled) { settled = true; fn() } } + const result = store.updater.update(deletions, insertions, (_uri, ok, message, response) => { + if (ok) done(resolve) + else done(() => reject(updateFailureError(message, response, options.failureMessage))) + }) + if (result && typeof (result as Promise).then === 'function') { + ;(result as Promise).then(() => done(resolve), error => done(() => reject(error))) + } + }) +} diff --git a/src/profile/sections/shared/text.ts b/src/profile/sections/shared/text.ts new file mode 100644 index 00000000..11b5a8e5 --- /dev/null +++ b/src/profile/sections/shared/text.ts @@ -0,0 +1,7 @@ +export function trimText(value: string | null | undefined): string { + return value?.trim() || '' +} + +export function normalizeWhitespace(value: string | null | undefined): string { + return trimText(value).replace(/\s+/g, ' ') +} diff --git a/src/profile/sections/shared/types.ts b/src/profile/sections/shared/types.ts new file mode 100644 index 00000000..dc20b4b0 --- /dev/null +++ b/src/profile/sections/shared/types.ts @@ -0,0 +1,27 @@ +import { BlankNode, LiveStore, NamedNode, st } from 'rdflib' + +export type RowStatus = 'existing' | 'new' | 'modified' | 'deleted' + +export type MutationOps = { + create: T[] + update: T[] + remove: T[] +} + +export type RdfStatement = ReturnType +export type RdfResourceNode = NamedNode | BlankNode + +export type UpdateCallback = (_uri: string, ok: boolean, message?: string, response?: unknown) => void + +export type WebOperationResponse = { + ok?: boolean + status?: number + responseText?: string +} + +export type RdfFetcher = { + load?: (doc: NamedNode) => Promise + webOperation?: (...args: unknown[]) => Promise +} + +export type MutationCapableStore = LiveStore diff --git a/src/profile/sections/skills/mutations.ts b/src/profile/sections/skills/mutations.ts new file mode 100644 index 00000000..685f0b8e --- /dev/null +++ b/src/profile/sections/skills/mutations.ts @@ -0,0 +1,120 @@ +import { blankNode, LiveStore, NamedNode, Node, st, literal, sym } from 'rdflib' +import { ns } from '../../../util/ns' +import { SkillRow } from './types' +import { MutationOps, RdfResourceNode, RdfStatement } from '../shared/types' +import { collectLinkedNodeStatements, collectNodeStatements, findExistingNode, runUpdateTransport } from '../shared/rdfMutationHelpers' +import { logAndThrowMutationError } from '../shared/mutationErrors' +import { createIdNode } from '../shared/idNodeFactory' +import { profileVocabularies } from '../shared/profileVocabularies' +import { skillUpdatesFailedMessageText, skillUpdatesUnsupportedMessageText } from '../../texts' +import { trimText } from '../shared/text' + +export type SkillMutationPlan = MutationOps + +const skillVocabularyUri = profileVocabularies.escoSkill + +function buildSkillPublicIdNode(publicId: string): RdfResourceNode { + const normalized = trimText(publicId) + if (!normalized) return blankNode() + if (normalized.startsWith('_:')) { + return blankNode(normalized.slice(2)) + } + if (normalized.startsWith('skill:')) { + return sym(`${skillVocabularyUri}${normalized.slice('skill:'.length)}`) + } + if (normalized.startsWith(skillVocabularyUri)) { + return sym(normalized) + } + if (!normalized.includes(':')) { + return blankNode(normalized) + } + return sym(normalized) +} + +function collectSkillLinkStatementsByEntryValue( + store: LiveStore, + subject: NamedNode, + doc: NamedNode, + entryNode: string +) { + const existingNode = findExistingNode(store.each(subject, ns.schema('skills'), null, doc) as Node[], entryNode) + return existingNode + ? store.statementsMatching(subject, ns.schema('skills'), existingNode, doc) + : [] +} + +function buildSkillsStatements(subject: NamedNode, doc: NamedNode, node: NamedNode, skill: SkillRow) { + if (!skill.name) return [] + const publicIdNode = buildSkillPublicIdNode(skill.publicId) + + return [ + st(subject, ns.schema('skills'), node, doc), + st(node, ns.solid('publicId'), publicIdNode, doc), + st(publicIdNode, ns.schema('name'), literal(skill.name), doc) + ] +} + +async function mutateSkillsEntries(store: LiveStore, subject: NamedNode, skillOps: MutationOps) { + const doc = subject.doc() + const existingSkillNodes = store.each(subject, ns.schema('skills')) + + const deletions: RdfStatement[] = [] + const insertions: RdfStatement[] = [] + + const collectLinkedPublicIdStatements = (skillNode: NamedNode) => { + const linkedPublicIdStatements = collectLinkedNodeStatements(store, skillNode, ns.solid('publicId'), doc) + deletions.push(...linkedPublicIdStatements.linkedStatements) + } + + skillOps.remove.forEach((skill) => { + if (!skill.entryNode) return + const existingNode = findExistingNode(existingSkillNodes, skill.entryNode) + deletions.push(...collectSkillLinkStatementsByEntryValue(store, subject, doc, skill.entryNode)) + if (existingNode) { + if (existingNode.termType !== 'Literal') { + deletions.push(...collectNodeStatements(store, existingNode, doc)) + if (existingNode.termType === 'NamedNode') { + collectLinkedPublicIdStatements(existingNode as NamedNode) + } + } + } + }) + + skillOps.update.forEach((skill) => { + if (!skill.entryNode) return + const existingNode = findExistingNode(existingSkillNodes, skill.entryNode) + if (!existingNode) { + insertions.push(...buildSkillsStatements(subject, doc, createIdNode(doc), skill)) + return + } + deletions.push(...collectSkillLinkStatementsByEntryValue(store, subject, doc, skill.entryNode)) + if (existingNode.termType !== 'Literal') { + deletions.push(...collectNodeStatements(store, existingNode, doc)) + if (existingNode.termType === 'NamedNode') { + collectLinkedPublicIdStatements(existingNode as NamedNode) + } + } + if (existingNode.termType === 'NamedNode') { + insertions.push(...buildSkillsStatements(subject, doc, existingNode as NamedNode, skill)) + return + } + insertions.push(...buildSkillsStatements(subject, doc, createIdNode(doc), skill)) + }) + + skillOps.create.forEach((skill) => { + insertions.push(...buildSkillsStatements(subject, doc, createIdNode(doc), skill)) + }) + + await runUpdateTransport(store, doc, deletions, insertions, { + unsupportedMessage: skillUpdatesUnsupportedMessageText, + failureMessage: skillUpdatesFailedMessageText, + }) +} + +export async function processSkillsMutations(store: LiveStore, subject: NamedNode, mutationPlan: SkillMutationPlan) { + try { + await mutateSkillsEntries(store, subject, mutationPlan) + } catch (error) { + logAndThrowMutationError(error) + } +} diff --git a/src/profile/sections/skills/types.ts b/src/profile/sections/skills/types.ts new file mode 100644 index 00000000..fe927bc8 --- /dev/null +++ b/src/profile/sections/skills/types.ts @@ -0,0 +1,16 @@ +import { Node } from 'rdflib' +import { RowStatus } from '../shared/types' + +export interface SkillFields { + name: string + publicId: string +} + +export interface SkillDetails extends SkillFields { + entryNode: Node +} + +export interface SkillRow extends SkillFields { + entryNode: string + status: RowStatus +} diff --git a/src/profile/sections/social/helpers.ts b/src/profile/sections/social/helpers.ts new file mode 100644 index 00000000..edf9be9f --- /dev/null +++ b/src/profile/sections/social/helpers.ts @@ -0,0 +1,43 @@ +import { LiveStore, NamedNode } from 'rdflib' +import { ns } from '../../../util/ns' +import { SocialAccountOption } from './types' + +const FALLBACK_OPTIONS: SocialAccountOption[] = [ + { classUri: 'http://xmlns.com/foaf/0.1/OnlineAccount', label: 'Online Account', icon: '' }, + { classUri: 'https://schema.org/Person', label: 'Social Account', icon: '' } +] + +function normalize(value: string): string { + return value.trim().toLowerCase().replace(/[#:/]/g, ' ').replace(/account\b/g, '').replace(/[^a-z0-9]/g, '') +} + +export function findSocialAccountOption (options: SocialAccountOption[], value: string): SocialAccountOption | undefined { + const lower = value.trim().toLowerCase() + return options.find(option => option.classUri.toLowerCase() === lower || option.label.toLowerCase() === lower || normalize(option.label) === normalize(value) || normalize(option.classUri) === normalize(value)) +} + +export function getSocialAccountOptions (store: LiveStore): SocialAccountOption[] { + const classes = store.statementsMatching(undefined, ns.rdfs('subClassOf'), ns.foaf('OnlineAccount')) + .map(statement => statement.subject) + .filter((node): node is NamedNode => node.termType === 'NamedNode') + const options = classes.map(classNode => ({ + classUri: classNode.value, + label: store.any(classNode, ns.rdfs('label'))?.value || classNode.value, + icon: store.any(classNode, ns.foaf('icon'))?.value || '', + homepage: store.any(classNode, ns.foaf('homepage'))?.value, + userProfilePrefix: store.any(classNode, ns.foaf('userProfilePrefix'))?.value + })) + return options.length ? options : FALLBACK_OPTIONS +} + +export function nameForAccount (store: LiveStore, accountNode: NamedNode): string { + return store.any(accountNode, ns.foaf('name'))?.value || store.any(accountNode, ns.rdfs('label'))?.value || 'Unknown Account' +} + +export function iconForAccount (store: LiveStore, accountNode: NamedNode): string { + return store.any(accountNode, ns.foaf('icon'))?.value || '' +} + +export function homepageForAccount (store: LiveStore, accountNode: NamedNode): string { + return store.any(accountNode, ns.foaf('homepage'))?.value || '' +} diff --git a/src/profile/sections/social/mutations.ts b/src/profile/sections/social/mutations.ts new file mode 100644 index 00000000..43a40e2f --- /dev/null +++ b/src/profile/sections/social/mutations.ts @@ -0,0 +1,256 @@ +import { Collection, LiveStore, NamedNode, Node, st, literal, sym } from 'rdflib' +import { ns } from '../../../util/ns' +import { SocialAccountOption, SocialMutationPlan, SocialRow } from './types' +import { MutationOps, RdfStatement } from '../shared/types' +import { createIdNode } from '../shared/idNodeFactory' +import { + collectNodeStatements, + findExistingNode, + rdfNodeKey, + runUpdateTransport, + uniqueStatements +} from '../shared/rdfMutationHelpers' +import { logAndThrowMutationError } from '../shared/mutationErrors' +import { socialUpdatesFailedMessageText, socialUpdatesUnsupportedMessageText } from '../../texts' +import { findSocialAccountOption, getSocialAccountOptions } from './helpers' +import { presentSocial } from './selectors' +import { expandRdfList } from '../shared/rdfList' +import { trimText } from '../shared/text' + +/* The code below for handling reordering were generated by AI Model: GPT-5.3-Codex. */ +/* Prompt: can you implement on SocialEditDialog add the bentoIcon as the first item +on the row, you should be able to drag this button around and it should allow you +to change the order of the rows and that should change the order in the mutations +that happen for this data. */ + +function toObjectNode(value?: string): Node | null { + const normalized = trimText(value) + if (!normalized) return null + if (normalized.startsWith('http://') || normalized.startsWith('https://')) { + return sym(normalized) + } + return literal(normalized) +} + +function toAccountNameFromHomepage(homepage: string, profilePrefix?: string): string { + const homepageValue = trimText(homepage) + if (!homepageValue) return '' + + const prefixValue = trimText(profilePrefix) + if (prefixValue) { + let trimmed = homepageValue + const lowerPrefix = prefixValue.toLowerCase() + while (trimmed.toLowerCase().startsWith(lowerPrefix)) { + trimmed = trimmed.slice(prefixValue.length) + } + if (trimmed) return trimmed + + try { + const homepageUrl = new URL(homepageValue) + const prefixUrl = new URL(prefixValue) + const normalizeHost = (host: string) => host.toLowerCase().replace(/^www\./, '') + const homepageHost = normalizeHost(homepageUrl.hostname) + const prefixHost = normalizeHost(prefixUrl.hostname) + const prefixPath = (prefixUrl.pathname || '/').replace(/\/+$/, '') + const homepagePath = homepageUrl.pathname || '/' + + if (homepageHost === prefixHost && homepagePath.startsWith(prefixPath)) { + const remainder = homepagePath.slice(prefixPath.length).replace(/^\/+/, '') + if (remainder) return remainder + } + } catch { + // Ignore URL parsing issues and continue to generic normalization. + } + } + + return homepageValue + .replace(/^https?:\/\//i, '') + .replace(/^www\./i, '') +} + +function buildSocialStatements( + doc: NamedNode, + node: NamedNode, + row: SocialRow, + accountOption?: SocialAccountOption +) { + const inserts: RdfStatement[] = [] + if (accountOption?.classUri) { + inserts.push(st(node, ns.rdf('type'), sym(accountOption.classUri), doc)) + } + + const normalizedName = trimText(row.name) + const isOther = normalizedName.toLowerCase() === 'other' || accountOption?.classUri?.endsWith('#OtherAccount') + + if (isOther) { + const homepageNode = toObjectNode(row.homepage) + const iconNode = toObjectNode(row.icon) + if (homepageNode) inserts.push(st(node, ns.foaf('homepage'), homepageNode, doc)) + if (iconNode) inserts.push(st(node, ns.foaf('icon'), iconNode, doc)) + if (normalizedName && normalizedName.toLowerCase() !== 'other') { + inserts.push(st(node, ns.rdfs('label'), literal(normalizedName), doc)) + } + return inserts + } + + const accountPrefix = accountOption?.userProfilePrefix || accountOption?.homepage + const accountName = toAccountNameFromHomepage(trimText(row.homepage), accountPrefix) + if (accountName) { + inserts.push(st(node, ns.foaf('accountName'), literal(accountName), doc)) + } + + return inserts +} + +function collectListChainNodes(store: LiveStore, listHead: Node, doc: NamedNode): Node[] { + if (!store.any(listHead as any, ns.rdf('first'), null, doc)) return [] + + const visited = new Set() + const nodes: Node[] = [] + let current: Node | null = listHead + + while (current) { + const key = `${current.termType}:${current.value}` + if (visited.has(key)) break + visited.add(key) + nodes.push(current) + + const rest = store.any(current as any, ns.rdf('rest'), null, doc) as Node | null + if (!rest || (rest.termType === 'NamedNode' && rest.value === ns.rdf('nil').value)) { + break + } + current = rest + } + + return nodes +} + +function mergeSocialOps(existingRows: SocialRow[], socialOps: MutationOps): SocialRow[] { + const removeKeys = new Set( + socialOps.remove + .map((row) => trimText(row.entryNode)) + .filter(Boolean) + ) + + const updateByKey = new Map( + socialOps.update + .map((row) => [trimText(row.entryNode), row] as const) + .filter(([key]) => Boolean(key)) + ) + + const mergedExisting = existingRows + .filter((row) => !removeKeys.has(trimText(row.entryNode))) + .map((row) => { + const key = trimText(row.entryNode) + return updateByKey.get(key) || row + }) + + const creates = socialOps.create.filter((row) => { + return Boolean(trimText(row.name) || trimText(row.homepage) || trimText(row.icon)) + }) + + return [...mergedExisting, ...creates] +} + +function rowsFromOrderedInput(orderedRows: SocialRow[]): SocialRow[] { + return (orderedRows || []) + .filter((row) => row.status !== 'deleted') + .filter((row) => Boolean(trimText(row.name) || trimText(row.homepage) || trimText(row.icon) || trimText(row.entryNode))) + .map((row) => ({ + name: trimText(row.name), + icon: trimText(row.icon), + homepage: trimText(row.homepage), + entryNode: trimText(row.entryNode), + status: row.status + })) +} + +async function mutateSocialEntries( + store: LiveStore, + subject: NamedNode, + socialOps: MutationOps, + orderedRows?: SocialRow[] +) { + const doc = subject.doc() + const accountOptions = getSocialAccountOptions(store) + const optionForRow = (row: SocialRow) => findSocialAccountOption(accountOptions, trimText(row.name)) + + const existingRows: SocialRow[] = presentSocial(subject, store).accounts.map((account) => ({ + name: trimText(account.name), + icon: trimText(account.icon), + homepage: trimText(account.homepage), + entryNode: account.entryNode.value, + status: 'existing' + })) + + const nextRows = orderedRows && orderedRows.length + ? rowsFromOrderedInput(orderedRows) + : mergeSocialOps(existingRows, socialOps) + + const accountObjects = store.each(subject, ns.foaf('account'), null, doc) + const existingListNodes = uniqueStatements( + accountObjects.flatMap((objectNode) => { + return collectListChainNodes(store, objectNode, doc) + .flatMap((node) => store.statementsMatching(node as any, null, null, doc)) + }) + ) + + const existingAccountNodes = Array.from(new Map( + accountObjects + .flatMap((objectNode) => expandRdfList(store, objectNode)) + .filter((node) => node.termType === 'NamedNode') + .map((node) => [rdfNodeKey(node), node as NamedNode]) + ).values()) + + const rowEntryNodes = nextRows.map((row) => { + if (trimText(row.entryNode)) { + const existing = findExistingNode(existingAccountNodes as Node[], row.entryNode) + if (existing && existing.termType === 'NamedNode') return existing as NamedNode + } + return createIdNode(doc) + }) + + const retainedAccountNodeKeys = new Set(rowEntryNodes.map(rdfNodeKey)) + const retainedAccountNodes = existingAccountNodes.filter((node) => retainedAccountNodeKeys.has(rdfNodeKey(node))) + const removedAccountNodes = existingAccountNodes.filter((node) => !retainedAccountNodeKeys.has(rdfNodeKey(node))) + + const insertions: RdfStatement[] = [] + if (rowEntryNodes.length > 0) { + insertions.push(st(subject, ns.foaf('account'), new Collection(rowEntryNodes), doc)) + } + + nextRows.forEach((row, index) => { + insertions.push(...buildSocialStatements(doc, rowEntryNodes[index], row, optionForRow(row))) + }) + + const deletions = uniqueStatements([ + ...store.statementsMatching(subject, ns.foaf('account'), null, doc), + ...existingListNodes, + ...removedAccountNodes.flatMap((node) => collectNodeStatements(store, node, doc)), + ...retainedAccountNodes.flatMap((node) => [ + ...store.statementsMatching(node, ns.rdf('type'), null, doc), + ...store.statementsMatching(node, ns.foaf('accountName'), null, doc), + ...store.statementsMatching(node, ns.foaf('homepage'), null, doc), + ...store.statementsMatching(node, ns.foaf('icon'), null, doc), + ...store.statementsMatching(node, ns.rdfs('label'), null, doc) + ]) + ]) + + await runUpdateTransport(store, doc, deletions, uniqueStatements(insertions), { + unsupportedMessage: socialUpdatesUnsupportedMessageText, + failureMessage: socialUpdatesFailedMessageText, + }) +} + +export async function processSocialMutations( + store: LiveStore, + subject: NamedNode, + mutationPlan: SocialMutationPlan, + orderedRows?: SocialRow[] +) { + try { + await mutateSocialEntries(store, subject, mutationPlan, orderedRows) + } catch (error) { + logAndThrowMutationError(error) + } +} diff --git a/src/profile/sections/social/selectors.ts b/src/profile/sections/social/selectors.ts new file mode 100644 index 00000000..7e773810 --- /dev/null +++ b/src/profile/sections/social/selectors.ts @@ -0,0 +1,42 @@ +import { LiveStore, NamedNode } from 'rdflib' +import { ns } from '../../../util/ns' +import { Account, SocialPresentation } from './types' +import { expandRdfList } from '../shared/rdfList' +import { + homepageForAccount, + iconForAccount, + nameForAccount +} from './helpers' + + +export function presentSocial( + subject: NamedNode, + store: LiveStore +): SocialPresentation { + function accountAsObject (ac: NamedNode): Account { + return { + name: nameForAccount(store, ac), + icon: iconForAccount(store, ac), + homepage: homepageForAccount(store, ac), + entryNode: ac + } + + } + const accountNodes = store.each(subject, ns.foaf('account')) + const accountThings = accountNodes.flatMap(node => expandRdfList(store, node)) + // Deduplicate by foaf:accountName value + const accountNameSet = new Set() + const accounts: Account[] = [] + for (const ac of accountThings) { + if (ac.termType === 'NamedNode') { + const accountNameNode = store.any(ac as NamedNode, ns.foaf('accountName')) + const dedupeKey = accountNameNode ? accountNameNode.value : '' + if (!accountNameSet.has(dedupeKey)) { + accountNameSet.add(dedupeKey) + accounts.push(accountAsObject(ac as NamedNode)) + } + } + } + if (!accounts.length) return { accounts: [] } + return { accounts } +} diff --git a/src/profile/sections/social/types.ts b/src/profile/sections/social/types.ts new file mode 100644 index 00000000..0a532bb4 --- /dev/null +++ b/src/profile/sections/social/types.ts @@ -0,0 +1,31 @@ +import { Node } from 'rdflib' +import { MutationOps, RowStatus } from '../shared/types' + +export interface SocialFields { + name?: string + icon?: string + homepage?: string +} + +export interface Account extends SocialFields { + entryNode: Node +} + +export interface SocialPresentation { + accounts: Account[] +} + +export interface SocialRow extends SocialFields { + entryNode: string + status: RowStatus +} + +export type SocialAccountOption = { + classUri: string + label: string + icon: string + homepage?: string + userProfilePrefix?: string +} + +export type SocialMutationPlan = MutationOps diff --git a/src/profile/texts.ts b/src/profile/texts.ts new file mode 100644 index 00000000..3dacd968 --- /dev/null +++ b/src/profile/texts.ts @@ -0,0 +1,36 @@ +export const fallbackSaveUpdatesErrorMessageText = 'Failed to save profile updates.' +export const updaterUnsupportedStoreErrorMessageText = 'Profile updates are not supported by this store.' +export const bioMutationSaveFailedDebugText = 'Failed to save bio mutation.' +export const saveBioUpdatesFailedMessageText = 'Failed to save bio updates.' +export const contactInfoMutationSaveFailedDebugText = 'Failed to save contact information mutation.' +export const saveContactUpdatesFailedMessageText = 'Failed to save contact updates.' +export const educationMutationSaveFailedDebugText = 'Failed to save education mutation.' +export const saveEducationUpdatesFailedMessageText = 'Failed to save education updates.' +export const headingMutationSaveFailedDebugText = 'Failed to save heading mutation.' +export const saveHeadingUpdatesFailedMessageText = 'Failed to save heading updates.' +export const languageMutationSaveFailedDebugText = 'Failed to save language mutation.' +export const saveLanguageUpdatesFailedMessageText = 'Failed to save language updates.' +export const projectsMutationSaveFailedDebugText = 'Failed to save project mutation.' +export const saveProjectsUpdatesFailedMessageText = 'Failed to save project updates.' +export const resumeMutationSaveFailedDebugText = 'Failed to save resume mutation.' +export const saveResumeUpdatesFailedMessageText = 'Failed to save resume updates.' +export const saveSkillsUpdatesFailedMessageText = 'Failed to save skills updates.' +export const skillsMutationSaveFailedDebugText = 'Failed to save skills mutation.' +export const socialMutationSaveFailedDebugText = 'Failed to save social mutation.' +export const saveSocialUpdatesFailedMessageText = 'Failed to save social updates.' + +export const resumeUpdateEntryNotFoundErrorMessageText = 'Resume entry was not found.' +export const profileUpdateFailedMessageText = 'Profile update failed' +export const contactInfoUpdatesFailedMessageText = 'Failed to save contact info updates' +export const bioUpdatesFailedMessageText = 'Failed to save bio updates' +export const headingUpdatesFailedMessageText = 'Failed to save heading updates' +export const educationUpdatesFailedMessageText = 'Failed to save education updates' +export const languageUpdatesUnsupportedMessageText = 'Language updates are not supported by this store updater.' +export const languageUpdatesFailedMessageText = 'Failed to save languages' +export const projectUpdatesUnsupportedMessageText = 'Project updates are not supported by this store updater.' +export const projectUpdatesFailedMessageText = 'Failed to save projects' +export const resumeUpdatesFailedMessageText = 'Failed to save resume updates' +export const skillUpdatesUnsupportedMessageText = 'Skill updates are not supported by this store updater.' +export const skillUpdatesFailedMessageText = 'Failed to save skills' +export const socialUpdatesUnsupportedMessageText = 'Social updates are not supported by this store updater.' +export const socialUpdatesFailedMessageText = 'Failed to save updates' diff --git a/src/types.ts b/src/types.ts index d5deb033..3fb20332 100644 --- a/src/types.ts +++ b/src/types.ts @@ -3,6 +3,16 @@ import type { ACLContext, AccessMode, AccessSubject, Authorization, PatchPlan } import { LiveStore, NamedNode, Statement } from 'rdflib' import type { AccessControlSubject } from './acl/accessControlSubjects' import type { AccessRole, PublicAccessRole } from './acl/aclLogic' +import type { HeadingMutationPlan } from './profile/sections/heading/types' +import type { BioMutationPlan } from './profile/sections/bio/types' +import type { EducationMutationPlan } from './profile/sections/education/types' +import type { LanguageMutationPlan } from './profile/sections/languages/mutations' +import type { LanguageRow } from './profile/sections/languages/types' +import type { ProjectMutationPlan } from './profile/sections/projects/types' +import type { ResumeMutationPlan } from './profile/sections/resume/mutations' +import type { SkillMutationPlan } from './profile/sections/skills/mutations' +import type { SocialMutationPlan, SocialRow } from './profile/sections/social/types' +import type { ContactMutationPlan } from './profile/sections/contactInfo/types' export type AppDetails = { noun: string @@ -77,7 +87,28 @@ export interface ProfileLogic { loadMe: () => Promise, getPodRoot: (user: NamedNode) => NamedNode, getMainInbox: (user: NamedNode) => Promise, - findStorage: (me: NamedNode) => Node | null + findStorage: (me: NamedNode) => Node | null, + processHeadingMutations: (subject: NamedNode, mutationPlan: HeadingMutationPlan) => Promise, + processContactInfoMutations: (subject: NamedNode, mutationPlan: ContactMutationPlan) => Promise, + processBioMutations: (subject: NamedNode, mutationPlan: BioMutationPlan) => Promise, + processEducationMutations: (subject: NamedNode, mutationPlan: EducationMutationPlan) => Promise, + processLanguageMutations: (subject: NamedNode, mutationPlan: LanguageMutationPlan, orderedRows?: LanguageRow[]) => Promise, + processProjectsMutations: (subject: NamedNode, mutationPlan: ProjectMutationPlan) => Promise, + processResumeMutations: (subject: NamedNode, mutationPlan: ResumeMutationPlan) => Promise, + processSkillsMutations: (subject: NamedNode, mutationPlan: SkillMutationPlan) => Promise, + processSocialMutations: (subject: NamedNode, mutationPlan: SocialMutationPlan, orderedRows?: SocialRow[]) => Promise +} + +export interface ProfileMutationService { + processHeadingMutations: (subject: NamedNode, mutationPlan: HeadingMutationPlan) => Promise + processContactInfoMutations: (subject: NamedNode, mutationPlan: ContactMutationPlan) => Promise + processBioMutations: (subject: NamedNode, mutationPlan: BioMutationPlan) => Promise + processEducationMutations: (subject: NamedNode, mutationPlan: EducationMutationPlan) => Promise + processLanguageMutations: (subject: NamedNode, mutationPlan: LanguageMutationPlan, orderedRows?: LanguageRow[]) => Promise + processProjectsMutations: (subject: NamedNode, mutationPlan: ProjectMutationPlan) => Promise + processResumeMutations: (subject: NamedNode, mutationPlan: ResumeMutationPlan) => Promise + processSkillsMutations: (subject: NamedNode, mutationPlan: SkillMutationPlan) => Promise + processSocialMutations: (subject: NamedNode, mutationPlan: SocialMutationPlan, orderedRows?: SocialRow[]) => Promise } export interface AclLogic { diff --git a/src/util/containerLogic.ts b/src/util/containerLogic.ts index 35d825c9..27656ffa 100644 --- a/src/util/containerLogic.ts +++ b/src/util/containerLogic.ts @@ -1,5 +1,6 @@ import { NamedNode, Statement, sym } from 'rdflib' import { ns } from './ns' +import { isMissingError } from '../resource/resourceHttp' /** * Container-related class @@ -45,12 +46,24 @@ export function createContainerLogic(store) { body: ' ', // work around https://github.com/michielbdejong/community-server/issues/4#issuecomment-776222863 }) // Treat 409 as idempotent success: another process/request already created the container. - if (result.status === 409) { + if (result.status === 409 || result.status === 412) { return } if (result.status.toString()[0] !== '2') { throw new Error(`Not OK: got ${result.status} response while creating container at ${url}`) } + + } + + async function ensureContainer(url: string): Promise { + const containerNode = sym(url) + try { + await store.fetcher.load(containerNode) + return + } catch (error) { + if (!isMissingError(error)) throw error + } + await createContainer(url) } async function getContainerMembers(containerUrl: NamedNode): Promise { @@ -60,6 +73,7 @@ export function createContainerLogic(store) { return { isContainer, createContainer, + ensureContainer, getContainerElements, getContainerMembers, getContainerMemberCount diff --git a/src/util/utils.ts b/src/util/utils.ts index aee612b3..d930d492 100644 --- a/src/util/utils.ts +++ b/src/util/utils.ts @@ -1,5 +1,26 @@ import { NamedNode, sym } from 'rdflib' +export function isAbsoluteHttpUri (value: string | null | undefined): boolean { + if (!value) return false + try { + const parsed = new URL(value) + return parsed.protocol === 'http:' || parsed.protocol === 'https:' + } catch (_error) { + return false + } +} + +export function documentDirectoryUri (node: NamedNode): string | null { + const directory = node.doc().dir() + if (directory?.uri && isAbsoluteHttpUri(directory.uri)) return directory.uri + + const documentUri = node.doc().uri + if (!isAbsoluteHttpUri(documentUri)) return null + const withoutFragment = documentUri.split('#')[0] + const lastSlash = withoutFragment.lastIndexOf('/') + return lastSlash === -1 ? null : withoutFragment.slice(0, lastSlash + 1) +} + export function newThing(doc: NamedNode): NamedNode { return sym(doc.uri + '#' + 'id' + ('' + Date.now())) } From 7f861e8d8e3281a3d5592b9ece60ddaca0a22315 Mon Sep 17 00:00:00 2001 From: Sharon Stratsianis Date: Sun, 11 Oct 2026 16:21:53 +1100 Subject: [PATCH 2/2] Added selection as well to the profile service Prompt: Move the selection for profile pane to solid-logic profile service. Co-authored-by: GPT-5.6 Luna --- src/index.ts | 17 +- src/profile/ontology/social/accounts.ts | 62 +++ src/profile/profileLogic.ts | 19 + src/profile/sections/bio/mutations.ts | 6 +- src/profile/sections/bio/selectors.ts | 10 + src/profile/sections/bio/types.ts | 4 +- src/profile/sections/contactInfo/mutations.ts | 38 +- src/profile/sections/contactInfo/selectors.ts | 82 ++++ src/profile/sections/contactInfo/types.ts | 10 +- src/profile/sections/education/mutations.ts | 14 +- src/profile/sections/education/selectors.ts | 45 +++ src/profile/sections/education/types.ts | 4 +- src/profile/sections/heading/mutations.ts | 53 ++- src/profile/sections/heading/selectors.ts | 64 +++ src/profile/sections/heading/types.ts | 6 +- src/profile/sections/languages/mutations.ts | 56 +-- src/profile/sections/languages/selectors.ts | 2 +- src/profile/sections/languages/types.ts | 4 +- src/profile/sections/projects/mutations.ts | 13 +- src/profile/sections/projects/selectors.ts | 129 ++++++ src/profile/sections/projects/types.ts | 4 +- src/profile/sections/resume/mutations.ts | 19 +- src/profile/sections/resume/selectors.ts | 107 +++++ src/profile/sections/resume/types.ts | 4 +- src/profile/sections/shared/mutationErrors.ts | 8 +- src/profile/sections/shared/rdfList.ts | 5 + .../sections/shared/rdfMutationHelpers.ts | 10 +- src/profile/sections/skills/mutations.ts | 27 +- src/profile/sections/skills/selectors.ts | 40 ++ src/profile/sections/skills/types.ts | 4 +- src/profile/sections/social/helpers.ts | 29 +- src/profile/sections/social/mutations.ts | 51 ++- src/profile/sections/social/read.ts | 70 ++++ src/profile/sections/social/selectors.ts | 2 +- src/profile/sections/social/types.ts | 4 +- src/types.ts | 22 +- test/profile-bio-mutations.test.ts | 69 ++++ test/profile-contact-info-mutations.test.ts | 174 ++++++++ test/profile-cv-presenter.test.ts | 36 ++ test/profile-education-mutations.test.ts | 97 +++++ test/profile-heading-id-node.test.ts | 63 +++ test/profile-heading-mutations.test.ts | 288 +++++++++++++ test/profile-heading-presenter.test.ts | 82 ++++ test/profile-languages-mutations.test.ts | 362 +++++++++++++++++ test/profile-projects-mutations.test.ts | 215 ++++++++++ test/profile-rdf-list.test.ts | 32 ++ test/profile-resume-mutations.test.ts | 376 +++++++++++++++++ test/profile-skills-mutations.test.ts | 169 ++++++++ test/profile-social-mutations.test.ts | 380 ++++++++++++++++++ test/profile-social-presenter.test.ts | 91 +++++ test/profileProjectSelectors.test.ts | 77 ++++ test/profileReadLogic.test.ts | 55 +++ test/profileResumeSelectors.test.ts | 101 +++++ test/profileSocialRead.test.ts | 86 ++++ 54 files changed, 3633 insertions(+), 164 deletions(-) create mode 100644 src/profile/ontology/social/accounts.ts create mode 100644 src/profile/sections/bio/selectors.ts create mode 100644 src/profile/sections/contactInfo/selectors.ts create mode 100644 src/profile/sections/education/selectors.ts create mode 100644 src/profile/sections/heading/selectors.ts create mode 100644 src/profile/sections/projects/selectors.ts create mode 100644 src/profile/sections/resume/selectors.ts create mode 100644 src/profile/sections/skills/selectors.ts create mode 100644 src/profile/sections/social/read.ts create mode 100644 test/profile-bio-mutations.test.ts create mode 100644 test/profile-contact-info-mutations.test.ts create mode 100644 test/profile-cv-presenter.test.ts create mode 100644 test/profile-education-mutations.test.ts create mode 100644 test/profile-heading-id-node.test.ts create mode 100644 test/profile-heading-mutations.test.ts create mode 100644 test/profile-heading-presenter.test.ts create mode 100644 test/profile-languages-mutations.test.ts create mode 100644 test/profile-projects-mutations.test.ts create mode 100644 test/profile-rdf-list.test.ts create mode 100644 test/profile-resume-mutations.test.ts create mode 100644 test/profile-skills-mutations.test.ts create mode 100644 test/profile-social-mutations.test.ts create mode 100644 test/profile-social-presenter.test.ts create mode 100644 test/profileProjectSelectors.test.ts create mode 100644 test/profileReadLogic.test.ts create mode 100644 test/profileResumeSelectors.test.ts create mode 100644 test/profileSocialRead.test.ts diff --git a/src/index.ts b/src/index.ts index bb0d3619..ae426233 100644 --- a/src/index.ts +++ b/src/index.ts @@ -16,17 +16,18 @@ export { createTypeIndexLogic } from './typeIndex/typeIndexLogic' export { createDirectoryLogic, DEFAULT_DIRECTORY_CATALOG_URL, DEFAULT_DIRECTORY_SOURCES } from './directory/directoryLogic' export { createProfileMutationService } from './profile/profileMutationService' export type { BioMutationPlan, BioRow } from './profile/sections/bio/types' -export type { ContactMutationPlan, ContactPointRow, ContactAddressRow } from './profile/sections/contactInfo/types' -export type { EducationMutationPlan, EducationRow } from './profile/sections/education/types' -export type { HeadingMutationPlan, ProfileBasicRow } from './profile/sections/heading/types' +export type { BioDetails } from './profile/sections/bio/types' +export type { ContactMutationPlan, ContactPointRow, ContactAddressRow, ContactInfo, PointDetails, AddressDetails } from './profile/sections/contactInfo/types' +export type { EducationMutationPlan, EducationRow, EducationDetails } from './profile/sections/education/types' +export type { HeadingMutationPlan, ProfileBasicRow, ProfileDetails } from './profile/sections/heading/types' export type { LanguageMutationPlan } from './profile/sections/languages/mutations' -export type { LanguageRow } from './profile/sections/languages/types' -export type { ProjectMutationPlan, ProjectRow } from './profile/sections/projects/types' +export type { LanguageRow, LanguageDetails } from './profile/sections/languages/types' +export type { ProjectMutationPlan, ProjectRow, ProjectDetails } from './profile/sections/projects/types' export type { ResumeMutationPlan } from './profile/sections/resume/mutations' -export type { ResumeRow } from './profile/sections/resume/types' +export type { ResumeRow, RoleDetails, ResumeDetails } from './profile/sections/resume/types' export type { SkillMutationPlan } from './profile/sections/skills/mutations' -export type { SkillRow } from './profile/sections/skills/types' -export type { SocialMutationPlan, SocialRow } from './profile/sections/social/types' +export type { SkillRow, SkillDetails } from './profile/sections/skills/types' +export type { SocialMutationPlan, SocialRow, SocialPresentation, Account, SocialAccountOption } from './profile/sections/social/types' export type { MutationOps, RowStatus, RdfStatement } from './profile/sections/shared/types' export { isOriginUrl } from './acl/accessControlSubjects' export type { AccessControlSubjectKind, AccessControlSubject } from './acl/accessControlSubjects' diff --git a/src/profile/ontology/social/accounts.ts b/src/profile/ontology/social/accounts.ts new file mode 100644 index 00000000..37a12460 --- /dev/null +++ b/src/profile/ontology/social/accounts.ts @@ -0,0 +1,62 @@ +export type SocialAccountOntology = { + classUri: string + label: string + icon?: string + homepage?: string + userProfilePrefix?: string +} + +const socialMediaOntology = 'https://solidos.github.io/profile-pane/src/ontology/socialMedia.ttl#' + +function account ( + name: string, + label: string, + icon?: string, + homepage?: string, + userProfilePrefix?: string +): SocialAccountOntology { + return { + classUri: `${socialMediaOntology}${name}`, + label, + icon, + homepage, + userProfilePrefix + } +} + +export const socialAccountOntologies: readonly SocialAccountOntology[] = [ + account('BlueSkyAccount', 'Bluesky', 'https://solidos.github.io/solid-ui/src/icons/social/bluesky-1.svg', 'https://bsky.app/', 'https://bsky.app/profile/'), + account('DribbleAccount', 'Dribble', 'urn:profile-pane:icon:dribbble', 'https://dribbble.com/', 'https://dribbble.com/'), + account('DiscordAccount', 'Discord', 'urn:profile-pane:icon:discord', 'https://discord.com/', 'https://discord.com/users/'), + account('DiggAccount', 'Digg', 'https://solidos.github.io/solid-ui/src/icons/social/digg-icon.svg', 'https://www.digg.com/', 'https://www.digg.com/'), + account('FacebookAccount', 'Facebook', 'urn:profile-pane:icon:facebook', 'https://www.facebook.com/', 'https://www.facebook.com/'), + account('GithubAccount', 'Github', 'https://solidos.github.io/solid-ui/src/icons/social/github-icon.svg', 'https://github.com/', 'https://www.github.com/'), + account('InstagramAccount', 'Instagram', 'urn:profile-pane:icon:instagram', 'https://www.instagram.com/', 'https://www.instagram.com/'), + account('LinkedInAccount', 'LinkedIn', 'urn:profile-pane:icon:linkedin', 'https://linkedin.com/', 'https://www.linkedin.com/in/'), + account('MastodonAccount', 'Mastodon', 'https://solidos.github.io/solid-ui/src/icons/social/mastodon-2.svg', 'https://joinmastodon.org/', 'https://mastodon.social/'), + account('MatrixAccount', 'Matrix', 'https://solidos.github.io/solid-ui/src/icons/social/matrix-logo-black.svg', 'https://matrix.org/', 'https://matrix.to/#/'), + account('MediumAccount', 'Medium', 'https://solidos.github.io/solid-ui/src/icons/social/medium-logo-wordmark-black.svg', 'https://medium.com/', 'https://medium.com/'), + account('NostrAccount', 'Nostr', 'https://solidos.github.io/solid-ui/src/icons/social/nostr-icon-purple-on-white.svg', 'https://nostr.net/', 'https://primal.net/p/'), + account('OrcidAccount', 'ORCiD', 'https://solidos.github.io/solid-ui/src/icons/social/ORCID-1.svg', 'https://orcid.org/', 'https://orcid.org/'), + account('PinterestAccount', 'Pinterest', 'urn:profile-pane:icon:pinterest', 'https://pinterest.com/', 'https://pin.it/'), + account('RedditAccount', 'Reddit', 'https://solidos.github.io/solid-ui/src/icons/social/reddit-4.svg', 'https://reddit.com/', 'https://www.reddit.com/user/'), + account('SharechatAccount', 'ShareChat', 'urn:profile-pane:icon:sharechat', 'https://sharechat.com/', 'https://www.sharechat.com/user/'), + account('SnapchatAccount', 'Snapchat', 'urn:profile-pane:icon:snapchat', 'https://www.snapchat.com/', 'https://www.snapchat.com/add/'), + account('SpotifyAccount', 'Spotify', 'urn:profile-pane:icon:spotify', 'https://www.spotify.com/', 'https://www.spotify.com/user/'), + account('StravaAccount', 'Strava', 'https://solidos.github.io/solid-ui/src/icons/social/strava-2.svg', 'https://strava.com/', 'https://www.strava.com/athletes/'), + account('TelegramAccount', 'Telegram', 'urn:profile-pane:icon:telegram', 'https://telegram.org/', 'https://t.me/'), + account('TiktokAccount', 'TikTok', 'urn:profile-pane:icon:tiktok', 'https://www.tiktok.com/', 'https://www.tiktok.com/@'), + account('TumblrAccount', 'Tumblr', 'https://solidos.github.io/solid-ui/src/icons/social/tumblr-icon.svg', 'https://www.tumblr.com/', 'https://www.tumblr.com/'), + account('TwitterAccount', 'X', 'urn:profile-pane:icon:x', 'https://x.com/', 'https://x.com/'), + account('WhatsAppAccount', 'WhatsApp', 'urn:profile-pane:icon:whatsapp', 'https://www.whatsapp.com/', 'https://wa.me/'), + account('YouTubeAccount', 'YouTube', 'urn:profile-pane:icon:youtube', 'https://www.youtube.com/', 'https://www.youtube.com/'), + account('OtherAccount', 'Other', 'urn:profile-pane:starIcon') +] + +const socialAccountOntologyByClassUri = new Map( + socialAccountOntologies.map(ontology => [ontology.classUri, ontology]) +) + +export function socialAccountOntologyFor (classUri: string): SocialAccountOntology | undefined { + return socialAccountOntologyByClassUri.get(classUri) +} diff --git a/src/profile/profileLogic.ts b/src/profile/profileLogic.ts index ae01ea89..5c6f110f 100644 --- a/src/profile/profileLogic.ts +++ b/src/profile/profileLogic.ts @@ -12,6 +12,15 @@ import { differentOrigin, documentDirectoryUri, suggestPreferencesFile } from '. import { isMissingError } from '../resource/resourceHttp' import { ProfileLogic } from '../types' import { createProfileMutationService } from './profileMutationService' +import { getBio } from './sections/bio/selectors' +import { getContactInfo } from './sections/contactInfo/selectors' +import { getEducation } from './sections/education/selectors' +import { presentLanguages } from './sections/languages/selectors' +import { getSkillDetails, getSkills } from './sections/skills/selectors' +import { getHeading } from './sections/heading/selectors' +import { presentProjects } from './sections/projects/selectors' +import { presentCV } from './sections/resume/selectors' +import { getSocialAccounts } from './sections/social/read' export function createProfileLogic(store, authn, utilityLogic): ProfileLogic { const containerLogic = createContainerLogic(store) @@ -306,6 +315,16 @@ export function createProfileLogic(store, authn, utilityLogic): ProfileLogic { return { ...profileMutationService, + getBio: (subject: NamedNode) => getBio(subject, store), + getContactInfo: (subject: NamedNode) => getContactInfo(subject, store), + getEducation: (subject: NamedNode) => getEducation(subject, store), + getLanguages: (subject: NamedNode) => presentLanguages(subject, store), + getSkillDetails: (subject: NamedNode) => getSkillDetails(subject, store), + getSkills: (subject: NamedNode) => getSkills(subject, store), + getHeading: (subject: NamedNode) => getHeading(subject, store), + getResume: (subject: NamedNode) => presentCV(subject, store), + getProjects: (subject: NamedNode) => presentProjects(subject, store), + getSocial: (subject: NamedNode) => getSocialAccounts(subject, store), loadMe, getPodRoot, getMainInbox, diff --git a/src/profile/sections/bio/mutations.ts b/src/profile/sections/bio/mutations.ts index 3ab907cc..39e34212 100644 --- a/src/profile/sections/bio/mutations.ts +++ b/src/profile/sections/bio/mutations.ts @@ -4,7 +4,7 @@ import { BioMutationPlan, BioRow } from './types' import { MutationOps } from '../shared/types' import { replaceLiteralPredicateStatements, runUpdateTransport } from '../shared/rdfMutationHelpers' import { logAndThrowMutationError } from '../shared/mutationErrors' -import { bioUpdatesFailedMessageText, updaterUnsupportedStoreErrorMessageText } from '../../texts' +import { saveBioUpdatesFailedMessageText, updaterUnsupportedStoreErrorMessageText } from '../../texts' async function mutateBioEntry(store: LiveStore, subject: NamedNode, bioOps: MutationOps) { const doc = subject.doc() @@ -32,7 +32,7 @@ async function mutateBioEntry(store: LiveStore, subject: NamedNode, bioOps: Muta await runUpdateTransport(store, doc, deletions, insertions, { unsupportedMessage: updaterUnsupportedStoreErrorMessageText, - failureMessage: bioUpdatesFailedMessageText, + failureMessage: saveBioUpdatesFailedMessageText, }) } @@ -40,6 +40,6 @@ export async function processBioMutations(store: LiveStore, subject: NamedNode, try { await mutateBioEntry(store, subject, mutationPlan) } catch (error) { - logAndThrowMutationError(error) + logAndThrowMutationError(error, saveBioUpdatesFailedMessageText) } } diff --git a/src/profile/sections/bio/selectors.ts b/src/profile/sections/bio/selectors.ts new file mode 100644 index 00000000..a00f4b12 --- /dev/null +++ b/src/profile/sections/bio/selectors.ts @@ -0,0 +1,10 @@ +import { LiveStore, NamedNode } from 'rdflib' +import { ns } from '../../../util/ns' +import type { BioDetails } from './types' + +export function getBio (subject: NamedNode, store: LiveStore): BioDetails { + return { + description: store.anyJS(subject, ns.vcard('note')), + sourceNode: subject + } +} diff --git a/src/profile/sections/bio/types.ts b/src/profile/sections/bio/types.ts index 12a4f3a5..3d99815f 100644 --- a/src/profile/sections/bio/types.ts +++ b/src/profile/sections/bio/types.ts @@ -7,11 +7,11 @@ export interface BioFields { } export interface BioDetails extends BioFields { - entryNode: Node + sourceNode: Node } export interface BioRow extends BioFields { - entryNode: string + sourceNode: string status: RowStatus } diff --git a/src/profile/sections/contactInfo/mutations.ts b/src/profile/sections/contactInfo/mutations.ts index 45ad6759..8f047faa 100644 --- a/src/profile/sections/contactInfo/mutations.ts +++ b/src/profile/sections/contactInfo/mutations.ts @@ -5,7 +5,7 @@ import { MutationOps } from '../shared/types' import { collectLinkedNodeStatements, collectNodeStatements, findExistingNode, runUpdateTransport } from '../shared/rdfMutationHelpers' import { logAndThrowMutationError } from '../shared/mutationErrors' import { createIdNode } from '../shared/idNodeFactory' -import { contactInfoUpdatesFailedMessageText, updaterUnsupportedStoreErrorMessageText } from '../../texts' +import { saveContactUpdatesFailedMessageText, updaterUnsupportedStoreErrorMessageText } from '../../texts' import { buildAddressStatements, buildEmailStatements, buildPhoneStatements } from './rdfBuilders' async function mutatePhoneEntries(store: LiveStore, subject: NamedNode, phoneOps: MutationOps) { @@ -15,8 +15,8 @@ async function mutatePhoneEntries(store: LiveStore, subject: NamedNode, phoneOps const insertions: any[] = [] phoneOps.remove.forEach((phone) => { - if (!phone.entryNode) return - const existingNode = findExistingNode(existingPhoneNodes, phone.entryNode) + if (!phone.sourceNode) return + const existingNode = findExistingNode(existingPhoneNodes, phone.sourceNode) if (existingNode) { const linkedPhoneStatements = collectLinkedNodeStatements(store, subject, ns.vcard('hasTelephone'), doc) const matchingLinkStatement = linkedPhoneStatements.linkStatements.find((statement) => statement.object?.value === existingNode.value) @@ -28,8 +28,8 @@ async function mutatePhoneEntries(store: LiveStore, subject: NamedNode, phoneOps }) phoneOps.update.forEach((phone) => { - if (!phone.entryNode) return - const existingNode = findExistingNode(existingPhoneNodes, phone.entryNode) + if (!phone.sourceNode) return + const existingNode = findExistingNode(existingPhoneNodes, phone.sourceNode) if (!existingNode) { insertions.push(...buildPhoneStatements(subject, doc, createIdNode(doc), phone)) return @@ -40,7 +40,9 @@ async function mutatePhoneEntries(store: LiveStore, subject: NamedNode, phoneOps deletions.push(matchingLinkStatement) } deletions.push(...collectNodeStatements(store, existingNode, doc)) - insertions.push(...buildPhoneStatements(subject, doc, existingNode, phone)) + const isDirectPhoneValue = existingNode.termType === 'NamedNode' && /^tel:/i.test(existingNode.value) + const targetNode = isDirectPhoneValue ? createIdNode(doc) : existingNode + insertions.push(...buildPhoneStatements(subject, doc, targetNode, phone)) }) phoneOps.create.forEach((phone) => { @@ -49,7 +51,7 @@ async function mutatePhoneEntries(store: LiveStore, subject: NamedNode, phoneOps await runUpdateTransport(store, doc, deletions, insertions, { unsupportedMessage: updaterUnsupportedStoreErrorMessageText, - failureMessage: contactInfoUpdatesFailedMessageText, + failureMessage: saveContactUpdatesFailedMessageText, }) } @@ -60,8 +62,8 @@ async function mutateEmailEntries(store: LiveStore, subject: NamedNode, emailOps const insertions: any[] = [] emailOps.remove.forEach((email) => { - if (!email.entryNode) return - const existingNode = findExistingNode(existingEmailNodes, email.entryNode) + if (!email.sourceNode) return + const existingNode = findExistingNode(existingEmailNodes, email.sourceNode) if (existingNode) { const linkedEmailStatements = collectLinkedNodeStatements(store, subject, ns.vcard('hasEmail'), doc) const matchingLinkStatement = linkedEmailStatements.linkStatements.find((statement) => statement.object?.value === existingNode.value) @@ -73,8 +75,8 @@ async function mutateEmailEntries(store: LiveStore, subject: NamedNode, emailOps }) emailOps.update.forEach((email) => { - if (!email.entryNode) return - const existingNode = findExistingNode(existingEmailNodes, email.entryNode) + if (!email.sourceNode) return + const existingNode = findExistingNode(existingEmailNodes, email.sourceNode) if (!existingNode) { insertions.push(...buildEmailStatements(subject, doc, createIdNode(doc), email)) return @@ -89,7 +91,7 @@ async function mutateEmailEntries(store: LiveStore, subject: NamedNode, emailOps await runUpdateTransport(store, doc, deletions, insertions, { unsupportedMessage: updaterUnsupportedStoreErrorMessageText, - failureMessage: contactInfoUpdatesFailedMessageText, + failureMessage: saveContactUpdatesFailedMessageText, }) } @@ -100,8 +102,8 @@ async function mutateAddressEntries(store: LiveStore, subject: NamedNode, addres const insertions: any[] = [] addressOps.remove.forEach((address) => { - if (!address.entryNode) return - const existingNode = findExistingNode(existingAddressNodes, address.entryNode) + if (!address.sourceNode) return + const existingNode = findExistingNode(existingAddressNodes, address.sourceNode) if (existingNode) { const linkedAddressStatements = collectLinkedNodeStatements(store, subject, ns.vcard('hasAddress'), doc) const matchingLinkStatement = linkedAddressStatements.linkStatements.find((statement) => statement.object?.value === existingNode.value) @@ -113,8 +115,8 @@ async function mutateAddressEntries(store: LiveStore, subject: NamedNode, addres }) addressOps.update.forEach((address) => { - if (!address.entryNode) return - const existingNode = findExistingNode(existingAddressNodes, address.entryNode) + if (!address.sourceNode) return + const existingNode = findExistingNode(existingAddressNodes, address.sourceNode) if (!existingNode) { insertions.push(...buildAddressStatements(subject, doc, createIdNode(doc), address)) return @@ -129,7 +131,7 @@ async function mutateAddressEntries(store: LiveStore, subject: NamedNode, addres await runUpdateTransport(store, doc, deletions, insertions, { unsupportedMessage: updaterUnsupportedStoreErrorMessageText, - failureMessage: contactInfoUpdatesFailedMessageText, + failureMessage: saveContactUpdatesFailedMessageText, }) } @@ -139,6 +141,6 @@ export async function processContactInfoMutations(store: LiveStore, subject: Nam await mutateEmailEntries(store, subject, mutationPlan.emailOps) await mutateAddressEntries(store, subject, mutationPlan.addressOps) } catch (error) { - logAndThrowMutationError(error) + logAndThrowMutationError(error, saveContactUpdatesFailedMessageText) } } diff --git a/src/profile/sections/contactInfo/selectors.ts b/src/profile/sections/contactInfo/selectors.ts new file mode 100644 index 00000000..f6800c24 --- /dev/null +++ b/src/profile/sections/contactInfo/selectors.ts @@ -0,0 +1,82 @@ +import { LiveStore, NamedNode, Node } from 'rdflib' +import { ns } from '../../../util/ns' +import { isNamedOrBlankNode } from '../shared/rdfMutationHelpers' +import type { RdfResourceNode } from '../shared/types' +import type { AddressDetails, ContactInfo, PointDetails } from './types' + +function termValue (term: { value?: string } | string | null | undefined): string { + if (!term) return '' + return typeof term === 'string' ? term : term.value || '' +} + +function isEmailValue (value: string): boolean { + const normalized = value.trim() + return /^mailto:/i.test(normalized) || /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(normalized) +} + +function isPhoneValue (value: string): boolean { + const normalized = value.trim() + return /^tel:/i.test(normalized) || /^[+()\-\s\d]{5,}$/.test(normalized) +} + +function resolvePointValueNode ( + store: LiveStore, + sourceNode: RdfResourceNode, + doc: NamedNode, + isExpectedValue: (value: string) => boolean +): Node | null { + if (isExpectedValue(termValue(sourceNode))) return sourceNode + + const inDocument = store.statementsMatching(sourceNode, ns.vcard('value'), null, doc) + const elsewhere = store.statementsMatching(sourceNode, ns.vcard('value')) + for (const statement of [...inDocument, ...elsewhere]) { + if (isRdfNode(statement.object) && isExpectedValue(termValue(statement.object))) return statement.object + } + return null +} + +function selectPoints ( + subject: NamedNode, + store: LiveStore, + predicate: NamedNode, + isExpectedValue: (value: string) => boolean +): PointDetails[] { + const doc = subject.doc() + return store.each(subject, predicate, null, doc).flatMap((sourceNode): PointDetails[] => { + if (!isNamedOrBlankNode(sourceNode)) return [] + const valueNode = resolvePointValueNode(store, sourceNode, doc, isExpectedValue) + if (!valueNode) return [] + const type = store.any(sourceNode, ns.rdf('type'), null, doc) + return [{ sourceNode, type, valueNode }] + }) +} + +function selectAddresses (subject: NamedNode, store: LiveStore): AddressDetails[] { + const doc = subject.doc() + return store.each(subject, ns.vcard('hasAddress'), null, doc) + .filter(isNamedOrBlankNode) + .map((sourceNode) => { + const type = store.any(sourceNode, ns.rdf('type'), null, doc) + return { + sourceNode, + ...(type ? { type } : {}), + streetAddress: termValue(store.any(sourceNode, ns.vcard('street-address'), null, doc)), + locality: termValue(store.any(sourceNode, ns.vcard('locality'), null, doc)), + region: termValue(store.any(sourceNode, ns.vcard('region'), null, doc)), + postalCode: termValue(store.any(sourceNode, ns.vcard('postal-code'), null, doc)), + countryName: termValue(store.any(sourceNode, ns.vcard('country-name'), null, doc)) + } + }) +} + +function isRdfNode (value: unknown): value is Node { + return Boolean(value && typeof value === 'object' && 'termType' in value) +} + +export function getContactInfo (subject: NamedNode, store: LiveStore): ContactInfo { + return { + emails: selectPoints(subject, store, ns.vcard('hasEmail'), isEmailValue), + phones: selectPoints(subject, store, ns.vcard('hasTelephone'), isPhoneValue), + addresses: selectAddresses(subject, store) + } +} diff --git a/src/profile/sections/contactInfo/types.ts b/src/profile/sections/contactInfo/types.ts index 1a28a533..1a178da2 100644 --- a/src/profile/sections/contactInfo/types.ts +++ b/src/profile/sections/contactInfo/types.ts @@ -3,12 +3,12 @@ import { MutationOps, RowStatus } from '../shared/types' /* using Node instead of NamedNode because the data could also be a blank node because it's coming from user-edited profile data */ export interface PointDetails { - entryNode: Node, - type: Node, + sourceNode: Node, + type: Node | null, valueNode: Node } export interface AddressDetails { - entryNode: Node, + sourceNode: Node, type?: Node, streetAddress?: string, locality?: string, @@ -26,7 +26,7 @@ export interface ContactInfo { export type ContactPointRow = { value: string type: string - entryNode: string + sourceNode: string status: RowStatus } @@ -37,7 +37,7 @@ export type ContactAddressRow = { postalCode?: string, countryName?: string type: string - entryNode: string + sourceNode: string status: RowStatus } diff --git a/src/profile/sections/education/mutations.ts b/src/profile/sections/education/mutations.ts index 2fdebb9c..bc4b6ec1 100644 --- a/src/profile/sections/education/mutations.ts +++ b/src/profile/sections/education/mutations.ts @@ -4,7 +4,7 @@ import { EducationMutationPlan, EducationRow } from './types' import { MutationOps } from '../shared/types' import { collectNodeStatements, findExistingNode, runUpdateTransport } from '../shared/rdfMutationHelpers' import { logAndThrowMutationError } from '../shared/mutationErrors' -import { educationUpdatesFailedMessageText, updaterUnsupportedStoreErrorMessageText } from '../../texts' +import { saveEducationUpdatesFailedMessageText, updaterUnsupportedStoreErrorMessageText } from '../../texts' const educationMembershipType = ns.schema('EducationalOccupationalCredential') @@ -40,16 +40,16 @@ async function mutateEducationEntries(store: LiveStore, subject: NamedNode, educ const insertions: any[] = [] educationOps.remove.forEach((education) => { - if (!education.entryNode) return - const existingNode = findExistingNode(existingEducationNodes, education.entryNode) + if (!education.sourceNode) return + const existingNode = findExistingNode(existingEducationNodes, education.sourceNode) if (existingNode) { deletions.push(...collectNodeStatements(store, existingNode, doc)) } }) educationOps.update.forEach((education) => { - if (!education.entryNode) return - const existingNode = findExistingNode(existingEducationNodes, education.entryNode) + if (!education.sourceNode) return + const existingNode = findExistingNode(existingEducationNodes, education.sourceNode) if (!existingNode) { insertions.push(...buildEducationStatements(subject, doc, store.bnode(), education)) return @@ -64,7 +64,7 @@ async function mutateEducationEntries(store: LiveStore, subject: NamedNode, educ await runUpdateTransport(store, doc, deletions, insertions, { unsupportedMessage: updaterUnsupportedStoreErrorMessageText, - failureMessage: educationUpdatesFailedMessageText, + failureMessage: saveEducationUpdatesFailedMessageText, }) } @@ -72,6 +72,6 @@ export async function processEducationMutations(store: LiveStore, subject: Named try { await mutateEducationEntries(store, subject, mutationPlan) } catch (error) { - logAndThrowMutationError(error) + logAndThrowMutationError(error, saveEducationUpdatesFailedMessageText) } } diff --git a/src/profile/sections/education/selectors.ts b/src/profile/sections/education/selectors.ts new file mode 100644 index 00000000..a1722d7e --- /dev/null +++ b/src/profile/sections/education/selectors.ts @@ -0,0 +1,45 @@ +import { LiveStore, NamedNode } from 'rdflib' +import { ns } from '../../../util/ns' +import { isNamedOrBlankNode } from '../shared/rdfMutationHelpers' +import type { RdfResourceNode } from '../shared/types' +import type { EducationDetails } from './types' + +const educationMembershipType = ns.schema('EducationalOccupationalCredential') + +function isEducationMembership (store: LiveStore, membership: RdfResourceNode, doc: NamedNode): boolean { + return store.holds(membership, ns.rdf('type'), educationMembershipType, doc) +} + +export function getEducation (subject: NamedNode, store: LiveStore): EducationDetails[] { + const doc = subject.doc() + const deduped = new Map() + const memberships = store + .each(null, ns.org('member'), subject, doc) + .filter(isNamedOrBlankNode) + .filter((membership) => isEducationMembership(store, membership, doc)) + + for (const membership of memberships) { + const item: EducationDetails = { + school: (store.anyJS(membership, ns.schema('name'), null, doc) as string | null) || '', + degree: (store.anyJS(membership, ns.schema('educationalCredentialAwarded'), null, doc) as string | null) || undefined, + location: (store.anyJS(membership, ns.schema('location'), null, doc) as string | null) || undefined, + startDate: (store.anyJS(membership, ns.schema('startDate'), null, doc) as string | null) || undefined, + endDate: (store.anyJS(membership, ns.schema('endDate'), null, doc) as string | null) || undefined, + description: (store.anyJS(membership, ns.schema('description'), null, doc) as string | null) || undefined, + sourceNode: membership + } + const key = [item.school, item.degree || '', item.location || '', item.startDate || '', item.endDate || ''].join('|') + const existing = deduped.get(key) + if (!existing || (existing.sourceNode.termType !== 'NamedNode' && item.sourceNode.termType === 'NamedNode')) { + deduped.set(key, item) + } + } + + return [...deduped.values()].sort((left, right) => { + const leftCurrent = !left.endDate + const rightCurrent = !right.endDate + if (leftCurrent !== rightCurrent) return leftCurrent ? -1 : 1 + return (right.endDate || '').localeCompare(left.endDate || '') || + (right.startDate || '').localeCompare(left.startDate || '') + }) +} diff --git a/src/profile/sections/education/types.ts b/src/profile/sections/education/types.ts index f24d4d6a..f3f692ab 100644 --- a/src/profile/sections/education/types.ts +++ b/src/profile/sections/education/types.ts @@ -12,11 +12,11 @@ export interface EducationFields { } export interface EducationDetails extends EducationFields { - entryNode: Node + sourceNode: Node } export interface EducationRow extends EducationFields { - entryNode: string + sourceNode: string status: RowStatus } diff --git a/src/profile/sections/heading/mutations.ts b/src/profile/sections/heading/mutations.ts index 7d31625b..96b1f019 100644 --- a/src/profile/sections/heading/mutations.ts +++ b/src/profile/sections/heading/mutations.ts @@ -7,9 +7,17 @@ import { logAndThrowMutationError } from '../shared/mutationErrors' import { createIdNode } from '../shared/idNodeFactory' import { ContactAddressRow, ContactPointRow } from '../contactInfo/types' import { buildAddressStatements, buildEmailStatements, buildPhoneStatements } from '../contactInfo/rdfBuilders' -import { headingUpdatesFailedMessageText, updaterUnsupportedStoreErrorMessageText } from '../../texts' +import { saveHeadingUpdatesFailedMessageText, updaterUnsupportedStoreErrorMessageText } from '../../texts' import { trimText } from '../shared/text' +const profileImagePredicates = [ + ns.sioc('avatar'), + ns.foaf('img'), + ns.vcard('logo'), + ns.vcard('photo'), + ns.foaf('depiction') +] + function splitPronouns(value?: string): { subjectPronoun?: string, objectPronoun?: string } { const normalized = trimText(value) if (!normalized) return {} @@ -21,12 +29,12 @@ function splitPronouns(value?: string): { subjectPronoun?: string, objectPronoun return { subjectPronoun, objectPronoun } } -function findOpWithExistingEntry(ops: T[], existingNodes: Node[]): T | undefined { - return ops.find((op) => Boolean(op.entryNode) && Boolean(findExistingNode(existingNodes, op.entryNode))) +function findOpWithExistingEntry(ops: T[], existingNodes: Node[]): T | undefined { + return ops.find((op) => Boolean(op.sourceNode) && Boolean(findExistingNode(existingNodes, op.sourceNode))) } -function findCreateOp(ops: T[]): T | undefined { - return ops.find((op) => !op.entryNode) +function findCreateOp(ops: T[]): T | undefined { + return ops.find((op) => !op.sourceNode) } async function mutatePhoneEntry(store: LiveStore, subject: NamedNode, phoneOps: MutationOps) { @@ -39,8 +47,8 @@ async function mutatePhoneEntry(store: LiveStore, subject: NamedNode, phoneOps: const updatePhone = findOpWithExistingEntry(phoneOps.update, existingPhoneNodes) const createPhone = findCreateOp(phoneOps.create) || phoneOps.create[0] - if (removePhone?.entryNode) { - const existingNode = findExistingNode(existingPhoneNodes, removePhone.entryNode) + if (removePhone?.sourceNode) { + const existingNode = findExistingNode(existingPhoneNodes, removePhone.sourceNode) if (existingNode) { const linkedPhoneStatements = collectLinkedNodeStatements(store, subject, ns.vcard('hasTelephone'), doc) const matchingLinkStatement = linkedPhoneStatements.linkStatements.find((statement) => statement.object?.value === existingNode.value) @@ -52,10 +60,12 @@ async function mutatePhoneEntry(store: LiveStore, subject: NamedNode, phoneOps: } if (updatePhone) { - const existingNode = findExistingNode(existingPhoneNodes, updatePhone.entryNode) + const existingNode = findExistingNode(existingPhoneNodes, updatePhone.sourceNode) if (existingNode) { deletions.push(...collectNodeStatements(store, existingNode, doc)) - insertions.push(...buildPhoneStatements(subject, doc, existingNode, updatePhone)) + const isDirectPhoneValue = existingNode.termType === 'NamedNode' && /^tel:/i.test(existingNode.value) + const targetNode = isDirectPhoneValue ? createIdNode(doc) : existingNode + insertions.push(...buildPhoneStatements(subject, doc, targetNode, updatePhone)) } } @@ -65,7 +75,7 @@ async function mutatePhoneEntry(store: LiveStore, subject: NamedNode, phoneOps: await runUpdateTransport(store, doc, deletions, insertions, { unsupportedMessage: updaterUnsupportedStoreErrorMessageText, - failureMessage: headingUpdatesFailedMessageText, + failureMessage: saveHeadingUpdatesFailedMessageText, }) } @@ -79,8 +89,8 @@ async function mutateEmailEntry(store: LiveStore, subject: NamedNode, emailOps: const updateEmail = findOpWithExistingEntry(emailOps.update, existingEmailNodes) const createEmail = findCreateOp(emailOps.create) || emailOps.create[0] - if (removeEmail?.entryNode) { - const existingNode = findExistingNode(existingEmailNodes, removeEmail.entryNode) + if (removeEmail?.sourceNode) { + const existingNode = findExistingNode(existingEmailNodes, removeEmail.sourceNode) if (existingNode) { const linkedEmailStatements = collectLinkedNodeStatements(store, subject, ns.vcard('hasEmail'), doc) const matchingLinkStatement = linkedEmailStatements.linkStatements.find((statement) => statement.object?.value === existingNode.value) @@ -92,7 +102,7 @@ async function mutateEmailEntry(store: LiveStore, subject: NamedNode, emailOps: } if (updateEmail) { - const existingNode = findExistingNode(existingEmailNodes, updateEmail.entryNode) + const existingNode = findExistingNode(existingEmailNodes, updateEmail.sourceNode) if (existingNode) { deletions.push(...collectNodeStatements(store, existingNode, doc)) insertions.push(...buildEmailStatements(subject, doc, existingNode, updateEmail)) @@ -105,7 +115,7 @@ async function mutateEmailEntry(store: LiveStore, subject: NamedNode, emailOps: await runUpdateTransport(store, doc, deletions, insertions, { unsupportedMessage: updaterUnsupportedStoreErrorMessageText, - failureMessage: headingUpdatesFailedMessageText, + failureMessage: saveHeadingUpdatesFailedMessageText, }) } @@ -119,8 +129,8 @@ async function mutateAddressEntry(store: LiveStore, subject: NamedNode, addressO const updateAddress = findOpWithExistingEntry(addressOps.update, existingAddressNodes) const createAddress = findCreateOp(addressOps.create) || addressOps.create[0] - if (removeAddress?.entryNode) { - const existingNode = findExistingNode(existingAddressNodes, removeAddress.entryNode) + if (removeAddress?.sourceNode) { + const existingNode = findExistingNode(existingAddressNodes, removeAddress.sourceNode) if (existingNode) { const linkedAddressStatements = collectLinkedNodeStatements(store, subject, ns.vcard('hasAddress'), doc) const matchingLinkStatement = linkedAddressStatements.linkStatements.find((statement) => statement.object?.value === existingNode.value) @@ -132,7 +142,7 @@ async function mutateAddressEntry(store: LiveStore, subject: NamedNode, addressO } if (updateAddress) { - const existingNode = findExistingNode(existingAddressNodes, updateAddress.entryNode) + const existingNode = findExistingNode(existingAddressNodes, updateAddress.sourceNode) if (existingNode) { deletions.push(...collectNodeStatements(store, existingNode, doc)) insertions.push(...buildAddressStatements(subject, doc, existingNode, updateAddress)) @@ -145,7 +155,7 @@ async function mutateAddressEntry(store: LiveStore, subject: NamedNode, addressO await runUpdateTransport(store, doc, deletions, insertions, { unsupportedMessage: updaterUnsupportedStoreErrorMessageText, - failureMessage: headingUpdatesFailedMessageText, + failureMessage: saveHeadingUpdatesFailedMessageText, }) } @@ -161,6 +171,9 @@ async function mutateBasicProfileEntry(store: LiveStore, subject: NamedNode, bas : (normalized.startsWith('http://') || normalized.startsWith('https://')) ? sym(normalized) : literal(normalized) + profileImagePredicates.forEach((predicate) => { + replacePredicateStatements(store, subject, predicate, doc, deletions, insertions, null) + }) replacePredicateStatements(store, subject, ns.vcard('hasPhoto'), doc, deletions, insertions, nextObject) } @@ -227,7 +240,7 @@ async function mutateBasicProfileEntry(store: LiveStore, subject: NamedNode, bas await runUpdateTransport(store, doc, deletions, insertions, { unsupportedMessage: updaterUnsupportedStoreErrorMessageText, - failureMessage: headingUpdatesFailedMessageText, + failureMessage: saveHeadingUpdatesFailedMessageText, }) } @@ -238,6 +251,6 @@ export async function processHeadingMutations(store: LiveStore, subject: NamedNo await mutateEmailEntry(store, subject, mutationPlan.emailOps) await mutateAddressEntry(store, subject, mutationPlan.addressOps) } catch (error) { - logAndThrowMutationError(error) + logAndThrowMutationError(error, saveHeadingUpdatesFailedMessageText) } } diff --git a/src/profile/sections/heading/selectors.ts b/src/profile/sections/heading/selectors.ts new file mode 100644 index 00000000..d91c2909 --- /dev/null +++ b/src/profile/sections/heading/selectors.ts @@ -0,0 +1,64 @@ +import { LiveStore, NamedNode, Node } from 'rdflib' +import { labelForNode } from '../../../directory/directoryHelpers' +import { ns } from '../../../util/ns' +import { getContactInfo } from '../contactInfo/selectors' +import type { AddressDetails, PointDetails } from '../contactInfo/types' +import { presentCV } from '../resume/selectors' +import type { RoleDetails } from '../resume/types' +import type { ProfileDetails } from './types' + +function isWorkPoint (point: PointDetails): boolean { + const type = point.type?.value.toLowerCase() || '' + return type === 'work' || type.endsWith('#work') || type.endsWith('/work') +} + +function selectHeadingRole (roles: RoleDetails[]): RoleDetails | undefined { + const current = roles.find(role => role.isCurrentRole || role.roleType?.replace(/\s+/g, '').toLowerCase() === 'currentrole') + return current || roles.find(role => { + const type = role.roleType?.replace(/\s+/g, '').toLowerCase() + return type !== 'currentrole' && type !== 'futurerole' + }) +} + +function imageForProfile (store: LiveStore, subject: NamedNode): string | undefined { + const imagePredicates = [ + ns.vcard('hasPhoto'), + ns.sioc('avatar'), + ns.foaf('img'), + ns.vcard('logo'), + ns.vcard('photo'), + ns.foaf('depiction') + ] + return imagePredicates + .map(predicate => store.any(subject, predicate)) + .find((image): image is Node => Boolean(image))?.value +} + +function formatLocation (address: AddressDetails | undefined): string | undefined { + if (!address) return undefined + if (address.countryName && address.locality) return `${address.locality}, ${address.countryName}` + return address.countryName || address.locality || undefined +} + +export function getHeading (subject: NamedNode, store: LiveStore): ProfileDetails { + const contact = getContactInfo(subject, store) + const primaryPhone = contact.phones.find(isWorkPoint) || contact.phones[0] + const primaryEmail = contact.emails.find(isWorkPoint) || contact.emails[0] + const pronounSubject = store.anyValue(subject, ns.solid('preferredSubjectPronoun')) || '' + const pronounObject = store.anyValue(subject, ns.solid('preferredObjectPronoun')) || '' + const headingRole = selectHeadingRole(presentCV(subject, store)) + + return { + name: labelForNode(store, subject), + nickname: store.anyValue(subject, ns.vcard('nickname')) || store.anyValue(subject, ns.foaf('nick')) || undefined, + imageSrc: imageForProfile(store, subject), + dateOfBirth: store.anyValue(subject, ns.vcard('bday')) || undefined, + jobTitle: headingRole?.title || undefined, + primaryPhone, + primaryEmail, + primaryAddress: contact.addresses[0], + location: formatLocation(contact.addresses[0]), + pronouns: pronounSubject ? `${pronounSubject}${pronounObject ? `/${pronounObject}` : ''}` : '', + sourceNode: subject + } +} diff --git a/src/profile/sections/heading/types.ts b/src/profile/sections/heading/types.ts index c5a82f6a..1d739e31 100644 --- a/src/profile/sections/heading/types.ts +++ b/src/profile/sections/heading/types.ts @@ -17,11 +17,11 @@ export interface ProfilePresentation { } export interface ProfileDetails extends ProfilePresentation { - entryNode: Node + sourceNode: Node } export interface ProfileRow extends ProfilePresentation { - entryNode: string + sourceNode: string status: RowStatus } @@ -31,7 +31,7 @@ export type ProfileBasicFields = Omit< > export type ProfileBasicRow = ProfileBasicFields & { - entryNode: string + sourceNode: string status: RowStatus } diff --git a/src/profile/sections/languages/mutations.ts b/src/profile/sections/languages/mutations.ts index 74a6ac80..5338e8f7 100644 --- a/src/profile/sections/languages/mutations.ts +++ b/src/profile/sections/languages/mutations.ts @@ -1,4 +1,4 @@ -import { Collection, LiveStore, NamedNode, st, sym, literal } from 'rdflib' +import { LiveStore, NamedNode, st, sym, literal } from 'rdflib' import { ns } from '../../../util/ns' import { LanguageRow } from './types' import { MutationOps, RdfResourceNode, RdfStatement } from '../shared/types' @@ -7,7 +7,7 @@ import { expandRdfList } from '../shared/rdfList' import { createIdNode } from '../shared/idNodeFactory' import { findExistingNode, isNamedOrBlankNode, rdfNodeKey, runUpdateTransport } from '../shared/rdfMutationHelpers' import { logAndThrowMutationError } from '../shared/mutationErrors' -import { languageUpdatesFailedMessageText, languageUpdatesUnsupportedMessageText } from '../../texts' +import { saveLanguageUpdatesFailedMessageText, languageUpdatesUnsupportedMessageText } from '../../texts' import { trimText } from '../shared/text' import { ianaLanguageVocabularyUri, languageCodeFromPublicId } from './normalization' @@ -17,13 +17,13 @@ export type LanguageMutationPlan = MutationOps function rowsFromOrderedInput(orderedRows: LanguageRow[]): LanguageRow[] { return (orderedRows || []) .filter((row) => row.status !== 'deleted') - .filter((row) => Boolean(trimText(row.publicId) || trimText(row.name) || trimText(row.entryNode))) + .filter((row) => Boolean(trimText(row.publicId) || trimText(row.name) || trimText(row.sourceNode))) .map((row) => ({ ...row, name: trimText(row.name), publicId: trimText(row.publicId), proficiency: trimText(row.proficiency), - entryNode: trimText(row.entryNode) + sourceNode: trimText(row.sourceNode) })) } @@ -38,17 +38,17 @@ function buildLanguageStatements( .map((row) => ({ name: trimText(row.name), publicId: languageCodeFromPublicId(row.publicId), - entryNode: trimText(row.entryNode) + sourceNode: trimText(row.sourceNode) })) .filter((row) => Boolean(row.publicId)) if (languageRows.length === 0) { - return { statements: [] as RdfStatement[], entryNodes: [] as RdfResourceNode[] } + return { statements: [] as RdfStatement[], sourceNodes: [] as RdfResourceNode[] } } - const entryNodes = languageRows.map((row) => { - const existingNode = row.entryNode - ? findExistingNode(existingLanguageNodes, row.entryNode) + const sourceNodes = languageRows.map((row) => { + const existingNode = row.sourceNode + ? findExistingNode(existingLanguageNodes, row.sourceNode) : null if (isNamedOrBlankNode(existingNode)) { @@ -59,18 +59,28 @@ function buildLanguageStatements( }) const statements: RdfStatement[] = [] - statements.push(st(subject, ns.schema('knowsLanguage'), new Collection(entryNodes), doc)) + const listNodes = sourceNodes.map(() => createIdNode(doc)) + statements.push(st(subject, ns.schema('knowsLanguage'), listNodes[0], doc)) + listNodes.forEach((listNode, index) => { + statements.push(st(listNode, ns.rdf('first'), sourceNodes[index], doc)) + statements.push(st( + listNode, + ns.rdf('rest'), + listNodes[index + 1] || ns.rdf('nil'), + doc + )) + }) - entryNodes.forEach((entryNode, index) => { + sourceNodes.forEach((sourceNode, index) => { const publicIdNode = sym(`${ianaLanguageVocabularyUri}${languageRows[index].publicId}`) - statements.push(st(entryNode, ns.solid('publicId'), publicIdNode, doc)) + statements.push(st(sourceNode, ns.solid('publicId'), publicIdNode, doc)) const existingNameStatement = store.holds(publicIdNode, ns.schema('name'), literal(languageRows[index].name, 'en'), doc) if (languageRows[index].name && !existingNameStatement) { statements.push(st(publicIdNode, ns.schema('name'), literal(languageRows[index].name, 'en'), doc)) } }) - return { statements, entryNodes } + return { statements, sourceNodes } } function collectListChainNodes(store: LiveStore, listHead: NamedNode, doc: NamedNode): NamedNode[] { @@ -97,18 +107,18 @@ function collectListChainNodes(store: LiveStore, listHead: NamedNode, doc: Named function mergeLanguageOps(existingRows: LanguageRow[], languageOps: MutationOps): LanguageRow[] { const byEntryNode = new Map() existingRows.forEach((row) => { - byEntryNode.set(row.entryNode, row) + byEntryNode.set(row.sourceNode, row) }) languageOps.remove.forEach((row) => { - if (!row.entryNode) return - byEntryNode.delete(row.entryNode) + if (!row.sourceNode) return + byEntryNode.delete(row.sourceNode) }) languageOps.update.forEach((row) => { - if (!row.entryNode) return - if (!byEntryNode.has(row.entryNode)) return - byEntryNode.set(row.entryNode, row) + if (!row.sourceNode) return + if (!byEntryNode.has(row.sourceNode)) return + byEntryNode.set(row.sourceNode, row) }) languageOps.create.forEach((row) => { @@ -147,7 +157,7 @@ async function mutateLanguageEntries( name: trimText(detail.name), publicId: trimText(detail.publicId), proficiency: trimText(detail.proficiency), - entryNode: detail.entryNode.value, + sourceNode: detail.sourceNode.value, status: 'existing' as const })) const nextRows = orderedRows && orderedRows.length @@ -176,7 +186,7 @@ async function mutateLanguageEntries( const { statements: insertions, - entryNodes: nextEntryNodes + sourceNodes: nextEntryNodes } = buildLanguageStatements(store, subject, doc, nextRows, existingLanguageNodes) const retainedEntryNodeKeys = new Set(nextEntryNodes.map(rdfNodeKey)) @@ -206,7 +216,7 @@ async function mutateLanguageEntries( await runUpdateTransport(store, doc, deletions, insertions, { unsupportedMessage: languageUpdatesUnsupportedMessageText, - failureMessage: languageUpdatesFailedMessageText, + failureMessage: saveLanguageUpdatesFailedMessageText, }) } @@ -219,6 +229,6 @@ export async function processLanguageMutations( try { await mutateLanguageEntries(store, subject, mutationPlan, orderedRows) } catch (error) { - logAndThrowMutationError(error) + logAndThrowMutationError(error, saveLanguageUpdatesFailedMessageText) } } diff --git a/src/profile/sections/languages/selectors.ts b/src/profile/sections/languages/selectors.ts index 0572fd31..58ad919f 100644 --- a/src/profile/sections/languages/selectors.ts +++ b/src/profile/sections/languages/selectors.ts @@ -125,7 +125,7 @@ export function presentLanguages(subject: NamedNode, store: LiveStore): Language name: languageAsText(store, lan), publicId: toStoredPublicIdValue(lan, store.any(lan as NamedNode, ns.solid('publicId')) || undefined), proficiency: store.anyValue(lan as NamedNode, ns.schema('proficiencyLevel')) || undefined, - entryNode: lan + sourceNode: lan })) .filter((item) => Boolean(item.name)) diff --git a/src/profile/sections/languages/types.ts b/src/profile/sections/languages/types.ts index 2e1a2532..569c7108 100644 --- a/src/profile/sections/languages/types.ts +++ b/src/profile/sections/languages/types.ts @@ -8,10 +8,10 @@ export interface LanguageFields { } export interface LanguageDetails extends LanguageFields { - entryNode: Node + sourceNode: Node } export interface LanguageRow extends LanguageFields { - entryNode: string + sourceNode: string status: RowStatus } diff --git a/src/profile/sections/projects/mutations.ts b/src/profile/sections/projects/mutations.ts index 88df90d7..bd7668d4 100644 --- a/src/profile/sections/projects/mutations.ts +++ b/src/profile/sections/projects/mutations.ts @@ -10,7 +10,7 @@ import { isRdfListNode } from '../shared/rdfList' import { rdfNodeKey } from '../shared/rdfMutationHelpers' import { runUpdateTransport } from '../shared/rdfMutationHelpers' import { logAndThrowMutationError } from '../shared/mutationErrors' -import { projectUpdatesFailedMessageText, projectUpdatesUnsupportedMessageText } from '../../texts' +import { saveProjectsUpdatesFailedMessageText, projectUpdatesUnsupportedMessageText } from '../../texts' import { trimText } from '../shared/text' function parseProjectUrl(value: string | null | undefined): URL | null { @@ -140,11 +140,10 @@ async function mutateProjectEntries(store: LiveStore, subject: NamedNode, projec } projectOps.remove.forEach((project) => { - const entryKey = normalizeProjectUrlKey(project.entryNode || '') + const entryKey = normalizeProjectUrlKey(project.sourceNode || '') const urlKey = normalizeProjectUrlKey(project.url) if (entryKey) { - removeExistingUrl(entryKey) - return + if (removeExistingUrl(entryKey) || !urlKey) return } if (urlKey) { removeExistingUrl(urlKey) @@ -153,7 +152,7 @@ async function mutateProjectEntries(store: LiveStore, subject: NamedNode, projec projectOps.update.forEach((project) => { const newLink = toProjectUrlNode(project) - const entryKey = normalizeProjectUrlKey(project.entryNode || '') + const entryKey = normalizeProjectUrlKey(project.sourceNode || '') const urlKey = normalizeProjectUrlKey(project.url) const removedDoc = (entryKey && removeExistingUrl(entryKey)) || (urlKey && removeExistingUrl(urlKey)) || doc if (newLink) { @@ -173,7 +172,7 @@ async function mutateProjectEntries(store: LiveStore, subject: NamedNode, projec const insertions = Array.from(batch.currentUrlsByKey.values()).map((url) => st(subject, ns.solid('community'), sym(url), batch.doc)) await runUpdateTransport(store, batch.doc, batch.cleanupStatements, insertions, { unsupportedMessage: projectUpdatesUnsupportedMessageText, - failureMessage: projectUpdatesFailedMessageText, + failureMessage: saveProjectsUpdatesFailedMessageText, }) } } @@ -182,6 +181,6 @@ export async function processProjectsMutations(store: LiveStore, subject: NamedN try { await mutateProjectEntries(store, subject, mutationPlan) } catch (err) { - logAndThrowMutationError(err) + logAndThrowMutationError(err, saveProjectsUpdatesFailedMessageText) } } diff --git a/src/profile/sections/projects/selectors.ts b/src/profile/sections/projects/selectors.ts new file mode 100644 index 00000000..4436594c --- /dev/null +++ b/src/profile/sections/projects/selectors.ts @@ -0,0 +1,129 @@ +import { LiveStore, NamedNode, Node, sym } from 'rdflib' +import { fallbackLabel, labelForNode } from '../../../directory/directoryHelpers' +import { ns } from '../../../util/ns' +import { expandCommunityNodes, linkedProjectNodeFromCommunityNode } from '../shared/projectCommunityNodes' +import type { ProjectDetails } from './types' + +function communityNodesForSubject(subject: NamedNode, store: LiveStore, doc: NamedNode): Node[] { + const inProfileDoc = store.each(subject, ns.solid('community'), null, doc) as Node[] + const inAnyDoc = store.each(subject, ns.solid('community'), null, null) as Node[] + const expandedNodes = [...inProfileDoc, ...inAnyDoc] + .flatMap(node => expandCommunityNodes(store, node, doc)) + + const seen = new Set() + return expandedNodes.filter(node => { + const key = `${node.termType}:${node.value}` + if (seen.has(key)) return false + seen.add(key) + return true + }) +} + +function clearStaleRequestedState(fetcher: unknown, docUrl: string): void { + const requested = (fetcher as { requested?: Record } | undefined)?.requested + if (!requested || typeof requested !== 'object') return + + const previous = requested[docUrl] + if ( + (typeof previous === 'string' && previous !== 'requested' && previous !== 'done') || + (typeof previous === 'number' && previous >= 400) + ) { + delete requested[docUrl] + } +} + +export async function ensureProjectDocumentsLoaded(subject: NamedNode, store: LiveStore): Promise { + const fetcher = (store as LiveStore & { fetcher?: { load?: (doc: NamedNode) => Promise } }).fetcher + if (!fetcher || typeof fetcher.load !== 'function') return + + const documents = new Map() + for (const node of communityNodesForSubject(subject, store, subject.doc())) { + const project = linkedProjectNodeFromCommunityNode(node, store) + if (project) documents.set(project.doc().value, project.doc()) + } + + await Promise.all(Array.from(documents.values()).map(async doc => { + clearStaleRequestedState(fetcher, doc.value) + try { + await fetcher.load(doc) + } catch { + // A failed linked document must not prevent other projects from being shown. + } + })) +} + +function projectNodeToUrl(node: Node): string { + return node.termType === 'NamedNode' || node.termType === 'Literal' ? node.value : '' +} + +function normalizeUrlKey(value: string): string { + const text = value.trim() + if (!text) return '' + try { + return new URL(text).href + } catch { + return text + } +} + +function toProjectNamedNode(node: Node, url: string): NamedNode | null { + if (node.termType === 'NamedNode') return node as NamedNode + try { + return sym(url) + } catch { + return null + } +} + +function imageForProject(store: LiveStore, project: NamedNode, doc: NamedNode | null): string | undefined { + return store.anyValue(project, ns.foaf('img'), null, doc) || + store.anyValue(project, ns.foaf('depiction'), null, doc) || + store.anyValue(project, ns.schema('image'), null, doc) || + undefined +} + +export async function presentProjects(subject: NamedNode, store: LiveStore): Promise { + await ensureProjectDocumentsLoaded(subject, store) + + const seen = new Set() + return communityNodesForSubject(subject, store, subject.doc()) + .map(communityNode => { + const linkedProject = linkedProjectNodeFromCommunityNode(communityNode, store) + const url = linkedProject?.value || projectNodeToUrl(communityNode) + const key = normalizeUrlKey(url) + if (!url || !key || seen.has(key)) return null + seen.add(key) + + const project = linkedProject || toProjectNamedNode(communityNode, url) + const projectDoc = project?.doc() ?? null + const name = project + ? store.anyValue(project, ns.foaf('name'), null, projectDoc) || undefined + : undefined + const orgName = project + ? store.anyValue(project, ns.vcard('organization-name'), null, projectDoc) || undefined + : undefined + const types = project + ? store.each(project, ns.rdf('type'), null, projectDoc) as NamedNode[] + : [] + const categoryType = types.find(type => + type.termType === 'NamedNode' && + (type.value === ns.schema('Project').value || type.value === ns.schema('Community').value) + ) + const category: ProjectDetails['category'] = categoryType?.value === ns.schema('Project').value + ? 'project' + : categoryType?.value === ns.schema('Community').value + ? 'community' + : 'unknown' + + return { + url, + title: name || (project ? labelForNode(store, project) : fallbackLabel(key)), + name, + orgName, + imageUrl: project ? imageForProject(store, project, projectDoc) : undefined, + category, + sourceNode: communityNode + } + }) + .filter((project): project is ProjectDetails => project !== null) +} diff --git a/src/profile/sections/projects/types.ts b/src/profile/sections/projects/types.ts index 47d250cb..6c59a347 100644 --- a/src/profile/sections/projects/types.ts +++ b/src/profile/sections/projects/types.ts @@ -15,11 +15,11 @@ export interface ProjectFields { } export interface ProjectDetails extends ProjectFields { - entryNode: Node + sourceNode: Node } export interface ProjectRow extends ProjectFields { - entryNode: string + sourceNode: string status: RowStatus } diff --git a/src/profile/sections/resume/mutations.ts b/src/profile/sections/resume/mutations.ts index f2214a28..a7eaf24c 100644 --- a/src/profile/sections/resume/mutations.ts +++ b/src/profile/sections/resume/mutations.ts @@ -8,7 +8,7 @@ import { createIdNode } from '../shared/idNodeFactory' import { trimText } from '../shared/text' import { resumeUpdateEntryNotFoundErrorMessageText, - resumeUpdatesFailedMessageText, + saveResumeUpdatesFailedMessageText, updaterUnsupportedStoreErrorMessageText } from '../../texts' @@ -82,7 +82,7 @@ function roleTypeNode(orgType: string): NamedNode | null { /* The following helper was generated by AI Model: GPT-5.3-Codex. Prompt: 'do you see anything I can do differently in the mutation code so that it's not so verbose and possible reusable accross sections' */ function buildOrganizationStatements(doc: NamedNode, membershipNode: Node, resumeData: ResumeRow) { - if (!resumeData.orgName && !resumeData.orgType && !resumeData.orgLocation && !resumeData.orgHomePage) { + if (!resumeData.orgName && !resumeData.orgType && !resumeData.orgLocation && !resumeData.orgHomePage && !resumeData.orgPublicId) { return [] } @@ -162,8 +162,8 @@ async function mutateResumeEntries(store: LiveStore, subject: NamedNode, resumeO const insertions: any[] = [] resumeOps.remove.forEach((resume) => { - if (!resume.entryNode) return - const existingNode = findExistingNode(existingResumeNodes, resume.entryNode) + if (!resume.sourceNode) return + const existingNode = findExistingNode(existingResumeNodes, resume.sourceNode) if (existingNode) { const linkedOrganizations = collectLinkedNodeStatements(store, existingNode, ns.org('organization'), doc) deletions.push(...linkedOrganizations.linkedStatements) @@ -172,14 +172,17 @@ async function mutateResumeEntries(store: LiveStore, subject: NamedNode, resumeO }) resumeOps.update.forEach((resume) => { - if (!resume.entryNode) return - const existingNode = findExistingNode(existingResumeNodes, resume.entryNode) + if (!resume.sourceNode) return + const existingNode = findExistingNode(existingResumeNodes, resume.sourceNode) if (!existingNode) { throw new Error(resumeUpdateEntryNotFoundErrorMessageText) } if ((existingNode as any).termType === 'BlankNode') { // Blank node labels are not always stable for remote updates; migrate to a named node. + const linkedOrganizations = collectLinkedNodeStatements(store, existingNode, ns.org('organization'), doc) + deletions.push(...linkedOrganizations.linkedStatements) + deletions.push(...collectNodeStatements(store, existingNode, doc)) const migratedNode = createIdNode(doc) insertions.push(...buildResumeStatements(subject, doc, migratedNode, resume, true)) return @@ -204,7 +207,7 @@ async function mutateResumeEntries(store: LiveStore, subject: NamedNode, resumeO await runUpdateTransport(store, doc, deletions, insertions, { unsupportedMessage: updaterUnsupportedStoreErrorMessageText, - failureMessage: resumeUpdatesFailedMessageText, + failureMessage: saveResumeUpdatesFailedMessageText, }) } @@ -214,6 +217,6 @@ export async function processResumeMutations(store: LiveStore, subject: NamedNod try { await mutateResumeEntries(store, subject, mutationPlan) } catch (error) { - logAndThrowMutationError(error) + logAndThrowMutationError(error, saveResumeUpdatesFailedMessageText) } } diff --git a/src/profile/sections/resume/selectors.ts b/src/profile/sections/resume/selectors.ts new file mode 100644 index 00000000..c333c146 --- /dev/null +++ b/src/profile/sections/resume/selectors.ts @@ -0,0 +1,107 @@ +import type { LiveStore, NamedNode, Node } from 'rdflib' +import { ns } from '../../../util/ns' +import { isNamedOrBlankNode } from '../shared/rdfMutationHelpers' +import type { RdfResourceNode } from '../shared/types' +import type { RoleDetails } from './types' + +const educationMembershipType = ns.schema('EducationalOccupationalCredential') +const vcardOrganizationType = ns.vcard('Organization').value +const roleTypes = ['PastRole', 'CurrentRole', 'FutureRole'] + +function localNameFromUri (uri: string): string { + const value = uri.trim() + if (!value) return '' + return value.match(/[#/]([^#/]+)$/)?.[1] || value +} + +function nodeToDisplayText (store: LiveStore, node: Node | null | undefined, doc: NamedNode): string { + if (!node) return '' + if (node.termType === 'Literal') return node.value + if (node.termType === 'NamedNode') { + return (store.anyJS(node as NamedNode, ns.rdfs('label'), null, doc) as string | null) || localNameFromUri(node.value) + } + return '' +} + +function roleTypeFromMembership (store: LiveStore, membership: RdfResourceNode, doc: NamedNode): string { + const matchingTypes = store + .each(membership, ns.rdf('type'), null, doc) + .filter((node): node is NamedNode => node.termType === 'NamedNode' && node.value.startsWith(ns.solid('').value)) + const roleType = matchingTypes.find(node => roleTypes.includes(localNameFromUri(node.value))) || matchingTypes[0] + return nodeToDisplayText(store, roleType, doc) +} + +function organizationTypeFromNode (store: LiveStore, organization: RdfResourceNode, doc: NamedNode): string { + const classification = store.any(organization, ns.org('classification'), null, doc) + const classificationText = nodeToDisplayText(store, classification, doc) + if (classificationText) return classificationText + + const organizationTypes = store + .each(organization, ns.rdf('type'), null, doc) + .filter((node): node is NamedNode => node.termType === 'NamedNode' && node.value !== vcardOrganizationType) + const preferred = organizationTypes.find(node => node.value.startsWith(ns.schema('').value)) || organizationTypes[0] + return nodeToDisplayText(store, preferred, doc) +} + +function isEducationMembership (store: LiveStore, membership: RdfResourceNode, doc: NamedNode): boolean { + return store.holds(membership, ns.rdf('type'), educationMembershipType, doc) +} + +function roleDedupeKey (role: RoleDetails): string { + return [role.title, role.orgName, role.startDate?.value || '', role.endDate?.value || ''].join('|') +} + +function sortRoles (roles: RoleDetails[]): RoleDetails[] { + return roles.sort((left, right) => { + const leftCurrent = !left.endDate + const rightCurrent = !right.endDate + if (leftCurrent !== rightCurrent) return leftCurrent ? -1 : 1 + + return (right.endDate?.value || '').localeCompare(left.endDate?.value || '') || + (right.startDate?.value || '').localeCompare(left.startDate?.value || '') + }) +} + +export function presentCV (subject: NamedNode, store: LiveStore): RoleDetails[] { + const doc = subject.doc() + const deduped = new Map() + const memberships = store + .each(null, ns.org('member'), subject, doc) + .filter(isNamedOrBlankNode) + .filter(membership => !isEducationMembership(store, membership, doc)) + + for (const membership of memberships) { + const organization = store.any(membership, ns.org('organization'), null, doc) + const organizationNode = isNamedOrBlankNode(organization) ? organization : undefined + const publicId = organizationNode && store.any(organizationNode, ns.solid('publicId'), null, doc) + const publicIdNode = isNamedOrBlankNode(publicId) ? publicId : undefined + const escoRole = store.any(membership, ns.org('role'), null, doc) + const roleName = isNamedOrBlankNode(escoRole) + ? store.anyJS(escoRole, ns.schema('name'), null, doc) as string | null + : null + const roleText = store.anyJS(membership, ns.vcard('role'), null, doc) as string | null + + const item: RoleDetails = { + title: roleText && roleName ? `${roleName} - ${roleText}` : roleText || roleName || '', + roleType: roleTypeFromMembership(store, membership, doc), + sourceNode: membership, + startDate: store.any(membership, ns.schema('startDate'), null, doc) as RoleDetails['startDate'], + endDate: store.any(membership, ns.schema('endDate'), null, doc) as RoleDetails['endDate'], + isCurrentRole: !store.any(membership, ns.schema('endDate'), null, doc), + orgName: (publicIdNode && store.anyJS(publicIdNode, ns.schema('name'), null, doc) as string | null) || + (organizationNode && store.anyJS(organizationNode, ns.schema('name'), null, doc) as string | null) || '', + orgPublicId: publicIdNode?.value || '', + orgType: organizationNode ? organizationTypeFromNode(store, organizationNode, doc) : '', + orgLocation: organizationNode ? (store.any(organizationNode, ns.org('location'), null, doc)?.value || '') : '', + orgHomePage: organizationNode ? (store.any(organizationNode, ns.schema('uri'), null, doc)?.value || '') : '', + description: (store.anyJS(membership, ns.schema('description'), null, doc) as string | null) || undefined + } + + const existing = deduped.get(roleDedupeKey(item)) + if (!existing || (existing.sourceNode.termType !== 'NamedNode' && item.sourceNode.termType === 'NamedNode')) { + deduped.set(roleDedupeKey(item), item) + } + } + + return sortRoles([...deduped.values()]) +} diff --git a/src/profile/sections/resume/types.ts b/src/profile/sections/resume/types.ts index 6895c491..aea555b0 100644 --- a/src/profile/sections/resume/types.ts +++ b/src/profile/sections/resume/types.ts @@ -4,11 +4,11 @@ import { RowStatus } from '../shared/types' export interface ResumeDetails extends RoleDetails {} export interface RoleDetails extends Role { - entryNode: Node + sourceNode: Node } export interface ResumeRow extends Role { - entryNode: string + sourceNode: string status: RowStatus } diff --git a/src/profile/sections/shared/mutationErrors.ts b/src/profile/sections/shared/mutationErrors.ts index 91e56e91..067039ab 100644 --- a/src/profile/sections/shared/mutationErrors.ts +++ b/src/profile/sections/shared/mutationErrors.ts @@ -1,7 +1,11 @@ import { error as debugError } from '../../../util/debug' -export function logAndThrowMutationError(error: unknown): never { +export function logAndThrowMutationError(error: unknown, message?: string): never { const rootError = error instanceof Error ? error : new Error(String(error)) debugError(rootError) - throw rootError + if (!message) throw rootError + const wrappedError = new Error(message) + Object.defineProperty(wrappedError, 'message', { value: message, enumerable: true, configurable: true, writable: true }) + Object.assign(wrappedError, { cause: rootError }) + throw wrappedError } diff --git a/src/profile/sections/shared/rdfList.ts b/src/profile/sections/shared/rdfList.ts index 5a4b605b..68038ca1 100644 --- a/src/profile/sections/shared/rdfList.ts +++ b/src/profile/sections/shared/rdfList.ts @@ -27,8 +27,13 @@ export function expandRdfList(store: Store, node: Node): Node[] { if (!first) return [currentNode] const items: Node[] = [] + const visitedListNodes = new Set() let listNode: Node | null = currentNode while (listNode) { + const listNodeKey = rdfNodeKey(listNode) + if (visitedListNodes.has(listNodeKey)) break + visitedListNodes.add(listNodeKey) + const listValue = store.any(listNode as NamedNode, ns.rdf('first')) as Node | null if (listValue) items.push(...inner(listValue)) const rest = store.any(listNode as NamedNode, ns.rdf('rest')) as Node | null diff --git a/src/profile/sections/shared/rdfMutationHelpers.ts b/src/profile/sections/shared/rdfMutationHelpers.ts index 49aee72e..c77f0e79 100644 --- a/src/profile/sections/shared/rdfMutationHelpers.ts +++ b/src/profile/sections/shared/rdfMutationHelpers.ts @@ -34,9 +34,9 @@ export function collectLinkedNodeStatements (store: LiveStore, sourceNode: Node, } } -export function findExistingNode (nodes: Node[], entryNode: string): Node | undefined { - const normalized = entryNode.startsWith('_:') ? entryNode.slice(2) : entryNode - return nodes.find(node => node.value === entryNode || node.value === normalized || node.value === `_:${normalized}`) +export function findExistingNode (nodes: Node[], sourceNode: string): Node | undefined { + const normalized = sourceNode.startsWith('_:') ? sourceNode.slice(2) : sourceNode + return nodes.find(node => node.value === sourceNode || node.value === normalized || node.value === `_:${normalized}`) } export function replacePredicateStatements (store: LiveStore, subject: NamedNode, predicate: NamedNode, doc: NamedNode, deletions: RdfStatement[], insertions: RdfStatement[], nextObject?: Node | null): void { @@ -87,7 +87,9 @@ export async function runUpdateTransport ( options: UpdateTransportOptions = {} ): Promise { if (!store.updater || typeof store.updater.update !== 'function') throw new Error(options.unsupportedMessage || updaterUnsupportedStoreErrorMessageText) - if (!store.updater.editable(doc)) throw new NotEditableError(`Cannot edit profile document ${doc.value}`) + if (typeof store.updater.editable === 'function' && !store.updater.editable(doc)) { + throw new NotEditableError(`Cannot edit profile document ${doc.value}`) + } if (!deletions.length && !insertions.length) return await new Promise((resolve, reject) => { let settled = false diff --git a/src/profile/sections/skills/mutations.ts b/src/profile/sections/skills/mutations.ts index 685f0b8e..bd256478 100644 --- a/src/profile/sections/skills/mutations.ts +++ b/src/profile/sections/skills/mutations.ts @@ -6,7 +6,7 @@ import { collectLinkedNodeStatements, collectNodeStatements, findExistingNode, r import { logAndThrowMutationError } from '../shared/mutationErrors' import { createIdNode } from '../shared/idNodeFactory' import { profileVocabularies } from '../shared/profileVocabularies' -import { skillUpdatesFailedMessageText, skillUpdatesUnsupportedMessageText } from '../../texts' +import { saveSkillsUpdatesFailedMessageText, skillUpdatesUnsupportedMessageText } from '../../texts' import { trimText } from '../shared/text' export type SkillMutationPlan = MutationOps @@ -20,10 +20,11 @@ function buildSkillPublicIdNode(publicId: string): RdfResourceNode { return blankNode(normalized.slice(2)) } if (normalized.startsWith('skill:')) { - return sym(`${skillVocabularyUri}${normalized.slice('skill:'.length)}`) + return sym(normalized) } if (normalized.startsWith(skillVocabularyUri)) { - return sym(normalized) + const suffix = normalized.slice(skillVocabularyUri.length) + return sym(suffix ? `skill:${suffix}` : normalized) } if (!normalized.includes(':')) { return blankNode(normalized) @@ -35,9 +36,9 @@ function collectSkillLinkStatementsByEntryValue( store: LiveStore, subject: NamedNode, doc: NamedNode, - entryNode: string + sourceNode: string ) { - const existingNode = findExistingNode(store.each(subject, ns.schema('skills'), null, doc) as Node[], entryNode) + const existingNode = findExistingNode(store.each(subject, ns.schema('skills'), null, doc) as Node[], sourceNode) return existingNode ? store.statementsMatching(subject, ns.schema('skills'), existingNode, doc) : [] @@ -67,9 +68,9 @@ async function mutateSkillsEntries(store: LiveStore, subject: NamedNode, skillOp } skillOps.remove.forEach((skill) => { - if (!skill.entryNode) return - const existingNode = findExistingNode(existingSkillNodes, skill.entryNode) - deletions.push(...collectSkillLinkStatementsByEntryValue(store, subject, doc, skill.entryNode)) + if (!skill.sourceNode) return + const existingNode = findExistingNode(existingSkillNodes, skill.sourceNode) + deletions.push(...collectSkillLinkStatementsByEntryValue(store, subject, doc, skill.sourceNode)) if (existingNode) { if (existingNode.termType !== 'Literal') { deletions.push(...collectNodeStatements(store, existingNode, doc)) @@ -81,13 +82,13 @@ async function mutateSkillsEntries(store: LiveStore, subject: NamedNode, skillOp }) skillOps.update.forEach((skill) => { - if (!skill.entryNode) return - const existingNode = findExistingNode(existingSkillNodes, skill.entryNode) + if (!skill.sourceNode) return + const existingNode = findExistingNode(existingSkillNodes, skill.sourceNode) if (!existingNode) { insertions.push(...buildSkillsStatements(subject, doc, createIdNode(doc), skill)) return } - deletions.push(...collectSkillLinkStatementsByEntryValue(store, subject, doc, skill.entryNode)) + deletions.push(...collectSkillLinkStatementsByEntryValue(store, subject, doc, skill.sourceNode)) if (existingNode.termType !== 'Literal') { deletions.push(...collectNodeStatements(store, existingNode, doc)) if (existingNode.termType === 'NamedNode') { @@ -107,7 +108,7 @@ async function mutateSkillsEntries(store: LiveStore, subject: NamedNode, skillOp await runUpdateTransport(store, doc, deletions, insertions, { unsupportedMessage: skillUpdatesUnsupportedMessageText, - failureMessage: skillUpdatesFailedMessageText, + failureMessage: saveSkillsUpdatesFailedMessageText, }) } @@ -115,6 +116,6 @@ export async function processSkillsMutations(store: LiveStore, subject: NamedNod try { await mutateSkillsEntries(store, subject, mutationPlan) } catch (error) { - logAndThrowMutationError(error) + logAndThrowMutationError(error, saveSkillsUpdatesFailedMessageText) } } diff --git a/src/profile/sections/skills/selectors.ts b/src/profile/sections/skills/selectors.ts new file mode 100644 index 00000000..526deeb7 --- /dev/null +++ b/src/profile/sections/skills/selectors.ts @@ -0,0 +1,40 @@ +import { LiveStore, NamedNode } from 'rdflib' +import { labelForNode } from '../../../directory/directoryHelpers' +import { ns } from '../../../util/ns' +import { isNamedOrBlankNode } from '../shared/rdfMutationHelpers' +import type { RdfResourceNode } from '../shared/types' +import type { SkillDetails } from './types' + +export function skillAsText (store: LiveStore, skill: RdfResourceNode): string { + const directName = store.anyJS(skill, ns.schema('name')) + if (directName) return directName + + const publicId = store.any(skill, ns.solid('publicId')) + if (isNamedOrBlankNode(publicId)) { + const name = store.anyJS(publicId, ns.schema('name')) + if (name) return name + if (publicId.termType === 'NamedNode') return labelForNode(store, publicId) + } + return '' +} + +export function getSkillDetails (subject: NamedNode, store: LiveStore): SkillDetails[] { + return store + .each(subject, ns.schema('skills')) + .filter(isNamedOrBlankNode) + .map((sourceNode) => { + const publicId = store.any(sourceNode, ns.solid('publicId')) + return { + name: skillAsText(store, sourceNode), + publicId: isNamedOrBlankNode(publicId) + ? publicId.termType === 'BlankNode' ? `_:${publicId.value}` : publicId.value + : '', + sourceNode + } + }) + .filter((detail) => detail.name !== '') +} + +export function getSkills (subject: NamedNode, store: LiveStore): string[] { + return getSkillDetails(subject, store).map((detail) => detail.name) +} diff --git a/src/profile/sections/skills/types.ts b/src/profile/sections/skills/types.ts index fe927bc8..40ba45cf 100644 --- a/src/profile/sections/skills/types.ts +++ b/src/profile/sections/skills/types.ts @@ -7,10 +7,10 @@ export interface SkillFields { } export interface SkillDetails extends SkillFields { - entryNode: Node + sourceNode: Node } export interface SkillRow extends SkillFields { - entryNode: string + sourceNode: string status: RowStatus } diff --git a/src/profile/sections/social/helpers.ts b/src/profile/sections/social/helpers.ts index edf9be9f..89c56fee 100644 --- a/src/profile/sections/social/helpers.ts +++ b/src/profile/sections/social/helpers.ts @@ -1,12 +1,8 @@ import { LiveStore, NamedNode } from 'rdflib' import { ns } from '../../../util/ns' +import { socialAccountOntologies, socialAccountOntologyFor } from '../../ontology/social/accounts' import { SocialAccountOption } from './types' -const FALLBACK_OPTIONS: SocialAccountOption[] = [ - { classUri: 'http://xmlns.com/foaf/0.1/OnlineAccount', label: 'Online Account', icon: '' }, - { classUri: 'https://schema.org/Person', label: 'Social Account', icon: '' } -] - function normalize(value: string): string { return value.trim().toLowerCase().replace(/[#:/]/g, ' ').replace(/account\b/g, '').replace(/[^a-z0-9]/g, '') } @@ -27,7 +23,15 @@ export function getSocialAccountOptions (store: LiveStore): SocialAccountOption[ homepage: store.any(classNode, ns.foaf('homepage'))?.value, userProfilePrefix: store.any(classNode, ns.foaf('userProfilePrefix'))?.value })) - return options.length ? options : FALLBACK_OPTIONS + return options.length + ? options + : socialAccountOntologies.map(({ classUri, label, icon, homepage, userProfilePrefix }) => ({ + classUri, + label, + icon: icon || '', + homepage, + userProfilePrefix + })) } export function nameForAccount (store: LiveStore, accountNode: NamedNode): string { @@ -35,9 +39,18 @@ export function nameForAccount (store: LiveStore, accountNode: NamedNode): strin } export function iconForAccount (store: LiveStore, accountNode: NamedNode): string { - return store.any(accountNode, ns.foaf('icon'))?.value || '' + const explicitIcon = store.any(accountNode, ns.foaf('icon'))?.value + if (explicitIcon) return explicitIcon + const classUri = store.any(accountNode, ns.rdf('type'))?.value + return (classUri && socialAccountOntologyFor(classUri)?.icon) || '' } export function homepageForAccount (store: LiveStore, accountNode: NamedNode): string { - return store.any(accountNode, ns.foaf('homepage'))?.value || '' + const explicitHomepage = store.any(accountNode, ns.foaf('homepage'))?.value + if (explicitHomepage) return explicitHomepage + const accountName = store.any(accountNode, ns.foaf('accountName'))?.value + const classUri = store.any(accountNode, ns.rdf('type'))?.value + const prefix = classUri && socialAccountOntologyFor(classUri)?.userProfilePrefix + if (accountName?.startsWith('http://') || accountName?.startsWith('https://')) return accountName + return accountName && prefix ? `${prefix}${accountName}` : '' } diff --git a/src/profile/sections/social/mutations.ts b/src/profile/sections/social/mutations.ts index 43a40e2f..e9d0aa1a 100644 --- a/src/profile/sections/social/mutations.ts +++ b/src/profile/sections/social/mutations.ts @@ -1,4 +1,4 @@ -import { Collection, LiveStore, NamedNode, Node, st, literal, sym } from 'rdflib' +import { LiveStore, NamedNode, Node, st, literal, sym } from 'rdflib' import { ns } from '../../../util/ns' import { SocialAccountOption, SocialMutationPlan, SocialRow } from './types' import { MutationOps, RdfStatement } from '../shared/types' @@ -11,9 +11,9 @@ import { uniqueStatements } from '../shared/rdfMutationHelpers' import { logAndThrowMutationError } from '../shared/mutationErrors' -import { socialUpdatesFailedMessageText, socialUpdatesUnsupportedMessageText } from '../../texts' +import { saveSocialUpdatesFailedMessageText, socialUpdatesUnsupportedMessageText } from '../../texts' import { findSocialAccountOption, getSocialAccountOptions } from './helpers' -import { presentSocial } from './selectors' +import { getSocialAccounts } from './read' import { expandRdfList } from '../shared/rdfList' import { trimText } from '../shared/text' @@ -69,14 +69,18 @@ function toAccountNameFromHomepage(homepage: string, profilePrefix?: string): st } function buildSocialStatements( + store: LiveStore, doc: NamedNode, node: NamedNode, row: SocialRow, - accountOption?: SocialAccountOption + accountOption?: SocialAccountOption, + existingNode?: NamedNode ) { const inserts: RdfStatement[] = [] if (accountOption?.classUri) { inserts.push(st(node, ns.rdf('type'), sym(accountOption.classUri), doc)) + } else if (existingNode) { + inserts.push(...store.statementsMatching(existingNode, ns.rdf('type'), null, doc)) } const normalizedName = trimText(row.name) @@ -128,20 +132,20 @@ function collectListChainNodes(store: LiveStore, listHead: Node, doc: NamedNode) function mergeSocialOps(existingRows: SocialRow[], socialOps: MutationOps): SocialRow[] { const removeKeys = new Set( socialOps.remove - .map((row) => trimText(row.entryNode)) + .map((row) => trimText(row.sourceNode)) .filter(Boolean) ) const updateByKey = new Map( socialOps.update - .map((row) => [trimText(row.entryNode), row] as const) + .map((row) => [trimText(row.sourceNode), row] as const) .filter(([key]) => Boolean(key)) ) const mergedExisting = existingRows - .filter((row) => !removeKeys.has(trimText(row.entryNode))) + .filter((row) => !removeKeys.has(trimText(row.sourceNode))) .map((row) => { - const key = trimText(row.entryNode) + const key = trimText(row.sourceNode) return updateByKey.get(key) || row }) @@ -155,12 +159,12 @@ function mergeSocialOps(existingRows: SocialRow[], socialOps: MutationOps row.status !== 'deleted') - .filter((row) => Boolean(trimText(row.name) || trimText(row.homepage) || trimText(row.icon) || trimText(row.entryNode))) + .filter((row) => Boolean(trimText(row.name) || trimText(row.homepage) || trimText(row.icon) || trimText(row.sourceNode))) .map((row) => ({ name: trimText(row.name), icon: trimText(row.icon), homepage: trimText(row.homepage), - entryNode: trimText(row.entryNode), + sourceNode: trimText(row.sourceNode), status: row.status })) } @@ -175,11 +179,11 @@ async function mutateSocialEntries( const accountOptions = getSocialAccountOptions(store) const optionForRow = (row: SocialRow) => findSocialAccountOption(accountOptions, trimText(row.name)) - const existingRows: SocialRow[] = presentSocial(subject, store).accounts.map((account) => ({ + const existingRows: SocialRow[] = getSocialAccounts(subject, store).accounts.map((account) => ({ name: trimText(account.name), icon: trimText(account.icon), homepage: trimText(account.homepage), - entryNode: account.entryNode.value, + sourceNode: account.sourceNode.value, status: 'existing' })) @@ -203,8 +207,8 @@ async function mutateSocialEntries( ).values()) const rowEntryNodes = nextRows.map((row) => { - if (trimText(row.entryNode)) { - const existing = findExistingNode(existingAccountNodes as Node[], row.entryNode) + if (trimText(row.sourceNode)) { + const existing = findExistingNode(existingAccountNodes as Node[], row.sourceNode) if (existing && existing.termType === 'NamedNode') return existing as NamedNode } return createIdNode(doc) @@ -216,11 +220,22 @@ async function mutateSocialEntries( const insertions: RdfStatement[] = [] if (rowEntryNodes.length > 0) { - insertions.push(st(subject, ns.foaf('account'), new Collection(rowEntryNodes), doc)) + const listNodes = rowEntryNodes.map(() => createIdNode(doc)) + insertions.push(st(subject, ns.foaf('account'), listNodes[0], doc)) + listNodes.forEach((listNode, index) => { + insertions.push(st(listNode, ns.rdf('first'), rowEntryNodes[index], doc)) + insertions.push(st( + listNode, + ns.rdf('rest'), + listNodes[index + 1] || ns.rdf('nil'), + doc + )) + }) } nextRows.forEach((row, index) => { - insertions.push(...buildSocialStatements(doc, rowEntryNodes[index], row, optionForRow(row))) + const existingNode = existingAccountNodes.find((node) => rdfNodeKey(node) === rdfNodeKey(rowEntryNodes[index])) + insertions.push(...buildSocialStatements(store, doc, rowEntryNodes[index], row, optionForRow(row), existingNode)) }) const deletions = uniqueStatements([ @@ -238,7 +253,7 @@ async function mutateSocialEntries( await runUpdateTransport(store, doc, deletions, uniqueStatements(insertions), { unsupportedMessage: socialUpdatesUnsupportedMessageText, - failureMessage: socialUpdatesFailedMessageText, + failureMessage: saveSocialUpdatesFailedMessageText, }) } @@ -251,6 +266,6 @@ export async function processSocialMutations( try { await mutateSocialEntries(store, subject, mutationPlan, orderedRows) } catch (error) { - logAndThrowMutationError(error) + logAndThrowMutationError(error, saveSocialUpdatesFailedMessageText) } } diff --git a/src/profile/sections/social/read.ts b/src/profile/sections/social/read.ts new file mode 100644 index 00000000..ece254b1 --- /dev/null +++ b/src/profile/sections/social/read.ts @@ -0,0 +1,70 @@ +import { NamedNode, Node, Store } from 'rdflib' +import { socialAccountOntologyFor } from '../../ontology/social/accounts' +import { ns } from '../../../util/ns' +import { expandRdfList } from '../shared/rdfList' +import type { Account, SocialPresentation } from './types' + +function accountTypes (store: Store, accountNode: NamedNode) { + return store.each(accountNode, ns.rdf('type')) + .filter((node): node is NamedNode => node.termType === 'NamedNode') + .map(node => socialAccountOntologyFor(node.value)) + .filter((ontology): ontology is NonNullable => Boolean(ontology)) +} + +export function socialAccountName (store: Store, accountNode: NamedNode): string { + const explicitName = store.any(accountNode, ns.foaf('name')) || store.any(accountNode, ns.rdfs('label')) + if (explicitName?.value) return explicitName.value + + return accountTypes(store, accountNode)[0]?.label || 'Unknown Account' +} + +export function socialAccountIcon (store: Store, accountNode: NamedNode): string { + const explicitIcon = store.any(accountNode, ns.foaf('icon')) + if (explicitIcon?.value) return explicitIcon.value + + return accountTypes(store, accountNode)[0]?.icon || '' +} + +export function socialAccountHomepage (store: Store, accountNode: NamedNode): string { + const explicitHomepage = store.any(accountNode, ns.foaf('homepage')) + if (explicitHomepage?.value) return explicitHomepage.value + + const accountName = store.any(accountNode, ns.foaf('accountName'))?.value.trim() || '' + if (/^https?:\/\//i.test(accountName)) return accountName + + const userProfilePrefix = accountTypes(store, accountNode)[0]?.userProfilePrefix + return userProfilePrefix && accountName ? `${userProfilePrefix}${accountName}` : '' +} + +function accountPresentation (store: Store, sourceNode: NamedNode): Account { + return { + name: socialAccountName(store, sourceNode), + icon: socialAccountIcon(store, sourceNode), + homepage: socialAccountHomepage(store, sourceNode), + sourceNode + } +} + +/** + * Reads `foaf:account` values in RDF list order. Accounts without an + * `foaf:accountName` have no stable duplicate key, so each is retained. + */ +export function getSocialAccounts (subject: NamedNode, store: Store): SocialPresentation { + const accountNodes = store.each(subject, ns.foaf('account')) as Node[] + const expandedAccounts = accountNodes.flatMap(accountNode => expandRdfList(store, accountNode)) + const accountNames = new Set() + const accounts: Account[] = [] + + for (const accountNode of expandedAccounts) { + if (accountNode.termType !== 'NamedNode') continue + const namedAccount = accountNode as NamedNode + + const accountName = store.any(namedAccount, ns.foaf('accountName'))?.value + if (accountName && accountNames.has(accountName)) continue + if (accountName) accountNames.add(accountName) + + accounts.push(accountPresentation(store, namedAccount)) + } + + return { accounts } +} diff --git a/src/profile/sections/social/selectors.ts b/src/profile/sections/social/selectors.ts index 7e773810..3fd28330 100644 --- a/src/profile/sections/social/selectors.ts +++ b/src/profile/sections/social/selectors.ts @@ -18,7 +18,7 @@ export function presentSocial( name: nameForAccount(store, ac), icon: iconForAccount(store, ac), homepage: homepageForAccount(store, ac), - entryNode: ac + sourceNode: ac } } diff --git a/src/profile/sections/social/types.ts b/src/profile/sections/social/types.ts index 0a532bb4..a2169205 100644 --- a/src/profile/sections/social/types.ts +++ b/src/profile/sections/social/types.ts @@ -8,7 +8,7 @@ export interface SocialFields { } export interface Account extends SocialFields { - entryNode: Node + sourceNode: Node } export interface SocialPresentation { @@ -16,7 +16,7 @@ export interface SocialPresentation { } export interface SocialRow extends SocialFields { - entryNode: string + sourceNode: string status: RowStatus } diff --git a/src/types.ts b/src/types.ts index 3fb20332..3ff409db 100644 --- a/src/types.ts +++ b/src/types.ts @@ -13,6 +13,15 @@ import type { ResumeMutationPlan } from './profile/sections/resume/mutations' import type { SkillMutationPlan } from './profile/sections/skills/mutations' import type { SocialMutationPlan, SocialRow } from './profile/sections/social/types' import type { ContactMutationPlan } from './profile/sections/contactInfo/types' +import type { BioDetails } from './profile/sections/bio/types' +import type { ContactInfo } from './profile/sections/contactInfo/types' +import type { EducationDetails } from './profile/sections/education/types' +import type { LanguageDetails } from './profile/sections/languages/types' +import type { SkillDetails } from './profile/sections/skills/types' +import type { ProfileDetails } from './profile/sections/heading/types' +import type { ProjectDetails } from './profile/sections/projects/types' +import type { RoleDetails } from './profile/sections/resume/types' +import type { SocialPresentation } from './profile/sections/social/types' export type AppDetails = { noun: string @@ -96,7 +105,17 @@ export interface ProfileLogic { processProjectsMutations: (subject: NamedNode, mutationPlan: ProjectMutationPlan) => Promise, processResumeMutations: (subject: NamedNode, mutationPlan: ResumeMutationPlan) => Promise, processSkillsMutations: (subject: NamedNode, mutationPlan: SkillMutationPlan) => Promise, - processSocialMutations: (subject: NamedNode, mutationPlan: SocialMutationPlan, orderedRows?: SocialRow[]) => Promise + processSocialMutations: (subject: NamedNode, mutationPlan: SocialMutationPlan, orderedRows?: SocialRow[]) => Promise, + getBio: (subject: NamedNode) => BioDetails, + getContactInfo: (subject: NamedNode) => ContactInfo, + getEducation: (subject: NamedNode) => EducationDetails[], + getLanguages: (subject: NamedNode) => LanguageDetails[], + getSkillDetails: (subject: NamedNode) => SkillDetails[], + getSkills: (subject: NamedNode) => string[], + getHeading: (subject: NamedNode) => ProfileDetails, + getResume: (subject: NamedNode) => RoleDetails[], + getProjects: (subject: NamedNode) => Promise, + getSocial: (subject: NamedNode) => SocialPresentation } export interface ProfileMutationService { @@ -111,6 +130,7 @@ export interface ProfileMutationService { processSocialMutations: (subject: NamedNode, mutationPlan: SocialMutationPlan, orderedRows?: SocialRow[]) => Promise } + export interface AclLogic { findAclDocUrl: (url: NamedNode) => Promise, findEffectiveAcl: (resourceURL: string | NamedNode) => Promise, diff --git a/test/profile-bio-mutations.test.ts b/test/profile-bio-mutations.test.ts new file mode 100644 index 00000000..77a1396e --- /dev/null +++ b/test/profile-bio-mutations.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it } from 'vitest' +import { graph, literal, st, sym } from 'rdflib' +import { ns } from '../src/util/ns' +import { getBio } from '../src/profile/sections/bio/selectors' +import { processBioMutations } from '../src/profile/sections/bio/mutations' +import { saveBioUpdatesFailedMessageText, updaterUnsupportedStoreErrorMessageText } from '../src/profile/texts' + +describe('Bio selectors and mutations', () => { + it('selector returns empty bio details from empty store', () => { + const store = graph() as any + const subject = sym('https://example.com/profile/card#me') + + const result = getBio(subject, store) + expect(result.sourceNode.value).toBe(subject.value) + expect(result.description).toBeUndefined() + }) + + it('mutation surfaces unsupported store updater errors', async () => { + const store = graph() as any + const subject = sym('https://example.com/profile/card#me') + + const plan = { create: [], update: [], remove: [] } + await expect(processBioMutations(store, subject, plan as any)).rejects.toMatchObject({ + message: saveBioUpdatesFailedMessageText, + cause: expect.objectContaining({ message: updaterUnsupportedStoreErrorMessageText }) + }) + }) + + it('selector reads the stored bio note', () => { + const store = graph() as any + const subject = sym('https://example.com/profile/card#me') + const doc = subject.doc() + + store.add(st(subject, ns.vcard('note'), literal('Building accessible Solid apps.'), doc)) + + const result = getBio(subject, store) + expect(result.description).toBe('Building accessible Solid apps.') + }) + + it('mutation writes and removes the vcard:note literal', async () => { + const store = graph() as any + const subject = sym('https://example.com/profile/card#me') + const doc = subject.doc() + + store.updater = { + update: (deletions: any[], insertions: any[], callback: Function) => { + deletions.forEach((statement) => store.remove(st(statement.subject, statement.predicate, statement.object, statement.why))) + insertions.forEach((statement) => store.add(statement.subject, statement.predicate, statement.object, statement.why)) + callback('', true) + } + } + + await processBioMutations(store, subject, { + create: [{ sourceNode: subject.value, description: ' New bio text ', status: 'new' }], + update: [], + remove: [] + } as any) + + expect(store.any(subject, ns.vcard('note'), null, doc)?.value).toBe('New bio text') + + await processBioMutations(store, subject, { + create: [], + update: [], + remove: [{ sourceNode: subject.value, description: '', status: 'deleted' }] + } as any) + + expect(store.any(subject, ns.vcard('note'), null, doc)).toBeNull() + }) +}) diff --git a/test/profile-contact-info-mutations.test.ts b/test/profile-contact-info-mutations.test.ts new file mode 100644 index 00000000..02bc4d41 --- /dev/null +++ b/test/profile-contact-info-mutations.test.ts @@ -0,0 +1,174 @@ +import { describe, expect, it } from 'vitest' +import { graph, literal, st, sym } from 'rdflib' +import { ns } from '../src/util/ns' +import { getContactInfo } from '../src/profile/sections/contactInfo/selectors' +import { processContactInfoMutations } from '../src/profile/sections/contactInfo/mutations' +import { saveContactUpdatesFailedMessageText, updaterUnsupportedStoreErrorMessageText } from '../src/profile/texts' + +describe('Contact info selectors and mutations', () => { + it('selector returns empty contact arrays from empty store', () => { + const store = graph() as any + const subject = sym('https://example.com/profile/card#me') + + const result = getContactInfo(subject, store) + expect(result.emails).toEqual([]) + expect(result.phones).toEqual([]) + expect(result.addresses).toEqual([]) + }) + + it('mutation surfaces unsupported store updater errors', async () => { + const store = graph() as any + const subject = sym('https://example.com/profile/card#me') + + const plan = { + phoneOps: { create: [], update: [], remove: [] }, + emailOps: { create: [], update: [], remove: [] }, + addressOps: { create: [], update: [], remove: [] } + } + + await expect(processContactInfoMutations(store, subject, plan as any)).rejects.toMatchObject({ + message: saveContactUpdatesFailedMessageText, + cause: expect.objectContaining({ message: updaterUnsupportedStoreErrorMessageText }) + }) + }) + + it('creates phone/email/address entries with #id + 13 digits and URI values', async () => { + const store = graph() as any + const subject = sym('https://example.com/profile/card#me') + const insertionsCaptured: any[] = [] + + store.updater = { + update: (_deletions: any[], insertions: any[], callback: Function) => { + insertionsCaptured.push(...insertions) + callback('', true) + } + } + + const plan = { + phoneOps: { + create: [{ value: '+1-111-222-3333', type: 'Voice', sourceNode: '', status: 'new' }], + update: [], + remove: [] + }, + emailOps: { + create: [{ value: 'jane@example.com', type: 'Internet', sourceNode: '', status: 'new' }], + update: [], + remove: [] + }, + addressOps: { + create: [{ streetAddress: 'Main St', locality: 'Boston', region: 'MA', postalCode: '02101', countryName: 'US', type: 'Home', sourceNode: '', status: 'new' }], + update: [], + remove: [] + } + } + + await processContactInfoMutations(store, subject, plan as any) + + const idPattern = /^https:\/\/example\.com\/profile\/card#id\d{13}$/ + + const emailLink = insertionsCaptured.find((statement) => statement.predicate.value === ns.vcard('hasEmail').value) + const phoneLink = insertionsCaptured.find((statement) => statement.predicate.value === ns.vcard('hasTelephone').value) + const addressLink = insertionsCaptured.find((statement) => statement.predicate.value === ns.vcard('hasAddress').value) + + expect(emailLink?.object?.value).toMatch(idPattern) + expect(phoneLink?.object?.value).toMatch(idPattern) + expect(addressLink?.object?.value).toMatch(idPattern) + + const emailValue = insertionsCaptured.find( + (statement) => statement.subject.value === emailLink.object.value && statement.predicate.value === ns.vcard('value').value + ) + const phoneValue = insertionsCaptured.find( + (statement) => statement.subject.value === phoneLink.object.value && statement.predicate.value === ns.vcard('value').value + ) + + expect(emailValue?.object?.termType).toBe('NamedNode') + expect(emailValue?.object?.value).toBe('mailto:jane@example.com') + expect(phoneValue?.object?.termType).toBe('NamedNode') + expect(phoneValue?.object?.value).toBe('tel:+1-111-222-3333') + }) + + it('selector resolves direct entry-node values and address details while ignoring invalid points', () => { + const store = graph() as any + const subject = sym('https://example.com/profile/card#me') + const doc = subject.doc() + const emailNode = sym('mailto:direct@example.com') + const phoneNode = sym('tel:+15551234567') + const invalidPhoneNode = sym(`${doc.value}#invalid-phone`) + const addressNode = sym(`${doc.value}#address-1`) + + store.add(st(subject, ns.vcard('hasEmail'), emailNode, doc)) + store.add(st(subject, ns.vcard('hasTelephone'), phoneNode, doc)) + store.add(st(subject, ns.vcard('hasTelephone'), invalidPhoneNode, doc)) + store.add(st(subject, ns.vcard('hasAddress'), addressNode, doc)) + store.add(st(addressNode, ns.rdf('type'), ns.vcard('Home'), doc)) + store.add(st(addressNode, ns.vcard('street-address'), literal('1 Main St'), doc)) + store.add(st(addressNode, ns.vcard('locality'), literal('Boston'), doc)) + store.add(st(addressNode, ns.vcard('country-name'), literal('US'), doc)) + + const result = getContactInfo(subject, store) + + expect(result.emails).toHaveLength(1) + expect(result.emails[0].valueNode.value).toBe('mailto:direct@example.com') + expect(result.phones).toHaveLength(1) + expect(result.phones[0].valueNode.value).toBe('tel:+15551234567') + expect(result.addresses).toHaveLength(1) + expect(result.addresses[0].type?.value).toBe(ns.vcard('Home').value) + expect(result.addresses[0].streetAddress).toBe('1 Main St') + expect(result.addresses[0].countryName).toBe('US') + }) + + it('migrates direct telephone links to a new entry node during updates', async () => { + const store = graph() as any + const subject = sym('https://example.com/profile/card#me') + const doc = subject.doc() + const oldPhone = sym('tel:+15551234567') + + store.add(st(subject, ns.vcard('hasTelephone'), oldPhone, doc)) + store.updater = { + update: (deletions: any[], insertions: any[], callback: Function) => { + deletions.forEach((statement) => store.remove(st(statement.subject, statement.predicate, statement.object, statement.why))) + insertions.forEach((statement) => store.add(statement.subject, statement.predicate, statement.object, statement.why)) + callback('', true) + } + } + + await processContactInfoMutations(store, subject, { + phoneOps: { + create: [], + update: [{ value: '+15557654321', type: 'Voice', sourceNode: oldPhone.value, status: 'updated' }], + remove: [] + }, + emailOps: { create: [], update: [], remove: [] }, + addressOps: { create: [], update: [], remove: [] } + } as any) + + expect(store.holds(subject, ns.vcard('hasTelephone'), oldPhone, doc)).toBe(false) + const newPhone = store.any(subject, ns.vcard('hasTelephone'), null, doc) + expect(newPhone?.termType).toBe('NamedNode') + expect(newPhone?.value).toMatch(/#id\d{13}$/) + expect(store.any(newPhone, ns.vcard('value'), null, doc)?.value).toBe('tel:+15557654321') + expect(getContactInfo(subject, store).phones[0].valueNode.value).toBe('tel:+15557654321') + }) + + it('selector falls back to vcard:value statements outside the subject document', () => { + const store = graph() as any + const subject = sym('https://example.com/profile/card#me') + const doc = subject.doc() + const emailEntry = sym(`${doc.value}#email-1`) + const phoneEntry = sym(`${doc.value}#phone-1`) + const externalDoc = sym('https://example.com/other.ttl') + + store.add(st(subject, ns.vcard('hasEmail'), emailEntry, doc)) + store.add(st(subject, ns.vcard('hasTelephone'), phoneEntry, doc)) + store.add(st(emailEntry, ns.vcard('value'), sym('mailto:external@example.com'), externalDoc)) + store.add(st(phoneEntry, ns.vcard('value'), sym('tel:+19998887777'), externalDoc)) + + const result = getContactInfo(subject, store) + + expect(result.emails).toHaveLength(1) + expect(result.emails[0].valueNode.value).toBe('mailto:external@example.com') + expect(result.phones).toHaveLength(1) + expect(result.phones[0].valueNode.value).toBe('tel:+19998887777') + }) + +}) diff --git a/test/profile-cv-presenter.test.ts b/test/profile-cv-presenter.test.ts new file mode 100644 index 00000000..2756c900 --- /dev/null +++ b/test/profile-cv-presenter.test.ts @@ -0,0 +1,36 @@ +import { beforeEach, describe, expect, it } from 'vitest' +import { presentCV } from '../src/profile/sections/resume/selectors' +import { blankNode, sym } from 'rdflib' +import { ns } from '../src/util/ns' +import { store } from '../src/index' + +describe('CVPresenter', () => { + const jane = sym('https://jane.doe.example/profile/card#me') + const doc = jane.doc() + + beforeEach(() => { + store.removeDocument(doc) + }) + + it('returns an empty list when no memberships exist', () => { + const result = presentCV(jane, store) + expect(result).toEqual([]) + }) + + it('presents a role entry from an org membership', () => { + const membership = blankNode() + const organization = blankNode() + + store.add(membership, ns.org('member'), jane, doc) + store.add(membership, ns.vcard('role'), 'Developer', doc) + store.add(membership, ns.schema('startDate'), '2024-01-01', doc) + store.add(membership, ns.org('organization'), organization, doc) + store.add(organization, ns.schema('name'), 'Inrupt', doc) + + const result = presentCV(jane, store) + + expect(result).toHaveLength(1) + expect(result[0].title).toBe('Developer') + expect(result[0].orgName).toBe('Inrupt') + }) +}) diff --git a/test/profile-education-mutations.test.ts b/test/profile-education-mutations.test.ts new file mode 100644 index 00000000..9f95e018 --- /dev/null +++ b/test/profile-education-mutations.test.ts @@ -0,0 +1,97 @@ +import { describe, expect, it } from 'vitest' +import { blankNode, graph, literal, st, sym } from 'rdflib' +import { ns } from '../src/util/ns' +import { getEducation } from '../src/profile/sections/education/selectors' +import { processEducationMutations } from '../src/profile/sections/education/mutations' +import { saveEducationUpdatesFailedMessageText, updaterUnsupportedStoreErrorMessageText } from '../src/profile/texts' + +describe('Education selectors and mutations', () => { + it('selector returns empty education list from empty store', () => { + const store = graph() as any + const subject = sym('https://example.com/profile/card#me') + + expect(getEducation(subject, store)).toEqual([]) + }) + + it('mutation surfaces unsupported store updater errors', async () => { + const store = graph() as any + const subject = sym('https://example.com/profile/card#me') + + const plan = { create: [], update: [], remove: [] } + await expect(processEducationMutations(store, subject, plan as any)).rejects.toMatchObject({ + message: saveEducationUpdatesFailedMessageText, + cause: expect.objectContaining({ message: updaterUnsupportedStoreErrorMessageText }) + }) + }) + + it('selector sorts current education first and dedupes blank nodes in favor of named nodes', () => { + const store = graph() as any + const subject = sym('https://example.com/profile/card#me') + const doc = subject.doc() + const namedMembership = sym(`${doc.value}#education-1`) + const duplicateBlankMembership = blankNode() + const pastMembership = sym(`${doc.value}#education-2`) + + ;[namedMembership, duplicateBlankMembership].forEach((node) => { + store.add(node, ns.org('member'), subject, doc) + store.add(node, ns.rdf('type'), ns.schema('EducationalOccupationalCredential'), doc) + store.add(node, ns.schema('name'), literal('Harvard'), doc) + store.add(node, ns.schema('educationalCredentialAwarded'), literal('BSc'), doc) + store.add(node, ns.schema('location'), literal('Cambridge'), doc) + store.add(node, ns.schema('startDate'), literal('2020-09-01'), doc) + }) + + store.add(pastMembership, ns.org('member'), subject, doc) + store.add(pastMembership, ns.rdf('type'), ns.schema('EducationalOccupationalCredential'), doc) + store.add(pastMembership, ns.schema('name'), literal('Oxford'), doc) + store.add(pastMembership, ns.schema('startDate'), literal('2015-09-01'), doc) + store.add(pastMembership, ns.schema('endDate'), literal('2019-06-01'), doc) + + const result = getEducation(subject, store) + + expect(result).toHaveLength(2) + expect(result[0].school).toBe('Harvard') + expect(result[0].sourceNode.value).toBe(namedMembership.value) + expect(result[1].school).toBe('Oxford') + }) + + it('mutation creates, updates, and removes education membership statements', async () => { + const store = graph() as any + const subject = sym('https://example.com/profile/card#me') + const doc = subject.doc() + const existingMembership = sym(`${doc.value}#education-existing`) + + store.updater = { + update: (deletions: any[], insertions: any[], callback: Function) => { + deletions.forEach((statement) => store.remove(st(statement.subject, statement.predicate, statement.object, statement.why))) + insertions.forEach((statement) => store.add(statement.subject, statement.predicate, statement.object, statement.why)) + callback('', true) + } + } + + store.add(existingMembership, ns.org('member'), subject, doc) + store.add(existingMembership, ns.rdf('type'), ns.schema('EducationalOccupationalCredential'), doc) + store.add(existingMembership, ns.schema('name'), literal('Old school'), doc) + + await processEducationMutations(store, subject, { + create: [{ school: 'New school', degree: 'MSc', location: 'Boston', startDate: '2024-01-01', endDate: '', description: 'New cohort', sourceNode: '', status: 'new' }], + update: [{ school: 'Updated school', degree: 'BA', location: 'London', startDate: '2020-01-01', endDate: '2023-01-01', description: 'Updated description', sourceNode: existingMembership.value, status: 'modified' }], + remove: [] + } as any) + + expect(store.any(existingMembership, ns.schema('name'), null, doc)?.value).toBe('Updated school') + expect(store.any(existingMembership, ns.schema('description'), null, doc)?.value).toBe('Updated description') + + const createdMemberships = store.each(null, ns.org('member'), subject, doc).filter((node) => node.value !== existingMembership.value) + expect(createdMemberships).toHaveLength(1) + expect(store.any(createdMemberships[0] as any, ns.schema('name'), null, doc)?.value).toBe('New school') + + await processEducationMutations(store, subject, { + create: [], + update: [], + remove: [{ school: 'Updated school', degree: 'BA', location: 'London', startDate: '2020-01-01', endDate: '2023-01-01', description: 'Updated description', sourceNode: existingMembership.value, status: 'deleted' }] + } as any) + + expect(store.statementsMatching(existingMembership, null, null, doc)).toHaveLength(0) + }) +}) diff --git a/test/profile-heading-id-node.test.ts b/test/profile-heading-id-node.test.ts new file mode 100644 index 00000000..084dab65 --- /dev/null +++ b/test/profile-heading-id-node.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it } from 'vitest' +import { graph, sym } from 'rdflib' +import { ns } from '../src/util/ns' +import { processHeadingMutations } from '../src/profile/sections/heading/mutations' + +describe('Heading mutations id-node writes', () => { + it('creates phone/email/address entries with #id + 13 digits and URI values', async () => { + const store = graph() as any + const subject = sym('https://example.com/profile/card#me') + const insertionsCaptured: any[] = [] + + store.updater = { + update: (_deletions: any[], insertions: any[], callback: Function) => { + insertionsCaptured.push(...insertions) + callback('', true) + } + } + + const plan = { + basicOps: { create: [], update: [], remove: [] }, + phoneOps: { + create: [{ value: '+1-999-888-7777', type: 'Voice', sourceNode: '', status: 'new' }], + update: [], + remove: [] + }, + emailOps: { + create: [{ value: 'alex@example.com', type: 'Internet', sourceNode: '', status: 'new' }], + update: [], + remove: [] + }, + addressOps: { + create: [{ streetAddress: 'North St', locality: 'NYC', region: 'NY', postalCode: '10001', countryName: 'US', type: 'Home', sourceNode: '', status: 'new' }], + update: [], + remove: [] + } + } + + await processHeadingMutations(store, subject, plan as any) + + const idPattern = /^https:\/\/example\.com\/profile\/card#id\d{13}$/ + + const emailLink = insertionsCaptured.find((statement) => statement.predicate.value === ns.vcard('hasEmail').value) + const phoneLink = insertionsCaptured.find((statement) => statement.predicate.value === ns.vcard('hasTelephone').value) + const addressLink = insertionsCaptured.find((statement) => statement.predicate.value === ns.vcard('hasAddress').value) + + expect(emailLink?.object?.value).toMatch(idPattern) + expect(phoneLink?.object?.value).toMatch(idPattern) + expect(addressLink?.object?.value).toMatch(idPattern) + + const emailValue = insertionsCaptured.find( + (statement) => statement.subject.value === emailLink.object.value && statement.predicate.value === ns.vcard('value').value + ) + const phoneValue = insertionsCaptured.find( + (statement) => statement.subject.value === phoneLink.object.value && statement.predicate.value === ns.vcard('value').value + ) + + expect(emailValue?.object?.termType).toBe('NamedNode') + expect(emailValue?.object?.value).toBe('mailto:alex@example.com') + expect(phoneValue?.object?.termType).toBe('NamedNode') + expect(phoneValue?.object?.value).toBe('tel:+1-999-888-7777') + }) + +}) diff --git a/test/profile-heading-mutations.test.ts b/test/profile-heading-mutations.test.ts new file mode 100644 index 00000000..1fecc1e2 --- /dev/null +++ b/test/profile-heading-mutations.test.ts @@ -0,0 +1,288 @@ +import { describe, expect, it } from 'vitest' +import { graph, literal, st, sym } from 'rdflib' +import { ns } from '../src/util/ns' +import { getHeading } from '../src/profile/sections/heading/selectors' +import { processHeadingMutations } from '../src/profile/sections/heading/mutations' +import { saveHeadingUpdatesFailedMessageText, updaterUnsupportedStoreErrorMessageText } from '../src/profile/texts' + +describe('Intro selectors and mutations', () => { + it('selectors return a stable profile shape from empty store', () => { + const store = graph() as any + const subject = sym('https://example.com/profile/card#me') + + expect(getHeading(subject, store).pronouns).toBe('') + + const profile = getHeading(subject, store) + expect(profile.sourceNode.value).toBe(subject.value) + expect(typeof profile.name).toBe('string') + }) + + it('mutation surfaces unsupported store updater errors', async () => { + const store = graph() as any + const subject = sym('https://example.com/profile/card#me') + + const plan = { + basicOps: { create: [], update: [], remove: [] }, + phoneOps: { create: [], update: [], remove: [] }, + emailOps: { create: [], update: [], remove: [] }, + addressOps: { create: [], update: [], remove: [] } + } + + await expect(processHeadingMutations(store, subject, plan as any)).rejects.toMatchObject({ + message: saveHeadingUpdatesFailedMessageText, + cause: expect.objectContaining({ message: updaterUnsupportedStoreErrorMessageText }) + }) + }) + + it('prefers work email and phone when present', () => { + const store = graph() as any + const subject = sym('https://example.com/profile/card#me') + const doc = subject.doc() + + const homeEmail = sym('https://example.com/profile/card#email-home') + const workEmail = sym('https://example.com/profile/card#email-work') + const homePhone = sym('https://example.com/profile/card#phone-home') + const workPhone = sym('https://example.com/profile/card#phone-work') + + store.add(st(subject, ns.vcard('hasEmail'), homeEmail, doc)) + store.add(st(homeEmail, ns.vcard('value'), sym('mailto:home@example.com'), doc)) + store.add(st(homeEmail, ns.rdf('type'), ns.vcard('Home'), doc)) + + store.add(st(subject, ns.vcard('hasEmail'), workEmail, doc)) + store.add(st(workEmail, ns.vcard('value'), sym('mailto:work@example.com'), doc)) + store.add(st(workEmail, ns.rdf('type'), ns.vcard('Work'), doc)) + + store.add(st(subject, ns.vcard('hasTelephone'), homePhone, doc)) + store.add(st(homePhone, ns.vcard('value'), sym('tel:+1111111111'), doc)) + store.add(st(homePhone, ns.rdf('type'), ns.vcard('Home'), doc)) + + store.add(st(subject, ns.vcard('hasTelephone'), workPhone, doc)) + store.add(st(workPhone, ns.vcard('value'), sym('tel:+2222222222'), doc)) + store.add(st(workPhone, ns.rdf('type'), ns.vcard('Work'), doc)) + + const profile = getHeading(subject, store) + + expect(profile.primaryEmail?.sourceNode.value).toBe(workEmail.value) + expect(profile.primaryPhone?.sourceNode.value).toBe(workPhone.value) + }) + + it('falls back to first valid email and phone when work type is missing', () => { + const store = graph() as any + const subject = sym('https://example.com/profile/card#me') + const doc = subject.doc() + + const firstEmail = sym('https://example.com/profile/card#email-first') + const secondEmail = sym('https://example.com/profile/card#email-second') + const firstPhone = sym('https://example.com/profile/card#phone-first') + const secondPhone = sym('https://example.com/profile/card#phone-second') + + store.add(st(subject, ns.vcard('hasEmail'), firstEmail, doc)) + store.add(st(firstEmail, ns.vcard('value'), sym('mailto:first@example.com'), doc)) + store.add(st(firstEmail, ns.rdf('type'), ns.vcard('Home'), doc)) + + store.add(st(subject, ns.vcard('hasEmail'), secondEmail, doc)) + store.add(st(secondEmail, ns.vcard('value'), sym('mailto:second@example.com'), doc)) + store.add(st(secondEmail, ns.rdf('type'), ns.vcard('Home'), doc)) + + store.add(st(subject, ns.vcard('hasTelephone'), firstPhone, doc)) + store.add(st(firstPhone, ns.vcard('value'), sym('tel:+3333333333'), doc)) + store.add(st(firstPhone, ns.rdf('type'), ns.vcard('Home'), doc)) + + store.add(st(subject, ns.vcard('hasTelephone'), secondPhone, doc)) + store.add(st(secondPhone, ns.vcard('value'), sym('tel:+4444444444'), doc)) + store.add(st(secondPhone, ns.rdf('type'), ns.vcard('Home'), doc)) + + const profile = getHeading(subject, store) + + expect(profile.primaryEmail?.sourceNode.value).toBe(firstEmail.value) + expect(profile.primaryPhone?.sourceNode.value).toBe(firstPhone.value) + }) + + it('uses the first current resume role for heading job title', () => { + const store = graph() as any + const subject = sym('https://example.com/profile/card#me') + const doc = subject.doc() + const currentRole = sym('https://example.com/profile/card#role-current') + const pastRole = sym('https://example.com/profile/card#role-past') + const currentOrg = sym('https://example.com/profile/card#org-current') + const pastOrg = sym('https://example.com/profile/card#org-past') + + store.add(st(currentRole, ns.org('member'), subject, doc)) + store.add(st(currentRole, ns.rdf('type'), ns.solid('CurrentRole'), doc)) + store.add(st(currentRole, ns.vcard('role'), literal('Staff Engineer'), doc)) + store.add(st(currentRole, ns.org('organization'), currentOrg, doc)) + store.add(st(currentOrg, ns.schema('name'), literal('Inrupt'), doc)) + store.add(st(currentRole, ns.schema('startDate'), literal('2024-01-01'), doc)) + + store.add(st(pastRole, ns.org('member'), subject, doc)) + store.add(st(pastRole, ns.rdf('type'), ns.solid('PastRole'), doc)) + store.add(st(pastRole, ns.vcard('role'), literal('Developer'), doc)) + store.add(st(pastRole, ns.org('organization'), pastOrg, doc)) + store.add(st(pastOrg, ns.schema('name'), literal('Old Co'), doc)) + store.add(st(pastRole, ns.schema('startDate'), literal('2020-01-01'), doc)) + store.add(st(pastRole, ns.schema('endDate'), literal('2023-12-01'), doc)) + + const profile = getHeading(subject, store) + + expect(profile.jobTitle).toBe('Staff Engineer') + }) + + it('falls back to the latest past resume role when no current role exists', () => { + const store = graph() as any + const subject = sym('https://example.com/profile/card#me') + const doc = subject.doc() + const olderPastRole = sym('https://example.com/profile/card#role-past-older') + const latestPastRole = sym('https://example.com/profile/card#role-past-latest') + const olderOrg = sym('https://example.com/profile/card#org-older') + const latestOrg = sym('https://example.com/profile/card#org-latest') + + store.add(st(olderPastRole, ns.org('member'), subject, doc)) + store.add(st(olderPastRole, ns.rdf('type'), ns.solid('PastRole'), doc)) + store.add(st(olderPastRole, ns.vcard('role'), literal('Developer'), doc)) + store.add(st(olderPastRole, ns.org('organization'), olderOrg, doc)) + store.add(st(olderOrg, ns.schema('name'), literal('Older Co'), doc)) + store.add(st(olderPastRole, ns.schema('startDate'), literal('2019-01-01'), doc)) + store.add(st(olderPastRole, ns.schema('endDate'), literal('2021-02-01'), doc)) + + store.add(st(latestPastRole, ns.org('member'), subject, doc)) + store.add(st(latestPastRole, ns.rdf('type'), ns.solid('PastRole'), doc)) + store.add(st(latestPastRole, ns.vcard('role'), literal('Senior Developer'), doc)) + store.add(st(latestPastRole, ns.org('organization'), latestOrg, doc)) + store.add(st(latestOrg, ns.schema('name'), literal('Latest Co'), doc)) + store.add(st(latestPastRole, ns.schema('startDate'), literal('2021-03-01'), doc)) + store.add(st(latestPastRole, ns.schema('endDate'), literal('2024-02-01'), doc)) + + const profile = getHeading(subject, store) + + expect(profile.jobTitle).toBe('Senior Developer') + }) + + it('writes trimmed basic heading fields and keeps nicknames in sync', async () => { + const store = graph() as any + const subject = sym('https://example.com/profile/card#me') + const doc = subject.doc() + + store.updater = { + update: (deletions: any[], insertions: any[], callback: Function) => { + deletions.forEach((statement) => store.remove(st(statement.subject, statement.predicate, statement.object, statement.why))) + insertions.forEach((statement) => store.add(statement.subject, statement.predicate, statement.object, statement.why)) + callback('', true) + } + } + + await processHeadingMutations(store, subject, { + basicOps: { + create: [{ + sourceNode: subject.value, + name: ' Jane Doe ', + nickname: ' janey ', + pronouns: ' She/Her ', + dateOfBirth: '2000-01-01', + imageSrc: 'https://example.com/jane.png', + status: 'new' + }], + update: [], + remove: [] + }, + phoneOps: { create: [], update: [], remove: [] }, + emailOps: { create: [], update: [], remove: [] }, + addressOps: { create: [], update: [], remove: [] } + } as any) + + expect(store.any(subject, ns.vcard('fn'), null, doc)?.value).toBe('Jane Doe') + expect(store.any(subject, ns.foaf('nick'), null, doc)?.value).toBe('janey') + expect(store.any(subject, ns.vcard('nickname'), null, doc)?.value).toBe('janey') + expect(store.any(subject, ns.solid('preferredSubjectPronoun'), null, doc)?.value).toBe('She') + expect(store.any(subject, ns.solid('preferredObjectPronoun'), null, doc)?.value).toBe('Her') + expect(store.any(subject, ns.vcard('role'), null, doc)).toBeNull() + expect(store.any(subject, ns.vcard('hasPhoto'), null, doc)?.termType).toBe('NamedNode') + + await processHeadingMutations(store, subject, { + basicOps: { + create: [], + update: [], + remove: [{ + sourceNode: subject.value, + name: '', + nickname: '', + pronouns: '', + dateOfBirth: '', + imageSrc: '', + status: 'deleted' + }] + }, + phoneOps: { create: [], update: [], remove: [] }, + emailOps: { create: [], update: [], remove: [] }, + addressOps: { create: [], update: [], remove: [] } + } as any) + + expect(store.any(subject, ns.vcard('fn'), null, doc)).toBeNull() + expect(store.any(subject, ns.foaf('nick'), null, doc)).toBeNull() + expect(store.any(subject, ns.solid('preferredSubjectPronoun'), null, doc)).toBeNull() + expect(store.any(subject, ns.solid('preferredObjectPronoun'), null, doc)).toBeNull() + expect(store.any(subject, ns.vcard('hasPhoto'), null, doc)).toBeNull() + }) + + it('replaces and removes legacy image predicates with the canonical photo field', async () => { + const store = graph() as any + const subject = sym('https://example.com/profile/card#me') + const doc = subject.doc() + const legacyImage = sym('https://example.com/legacy.png') + + store.add(st(subject, ns.foaf('img'), legacyImage, doc)) + store.updater = { + update: (deletions: any[], insertions: any[], callback: Function) => { + deletions.forEach((statement) => store.remove(st(statement.subject, statement.predicate, statement.object, statement.why))) + insertions.forEach((statement) => store.add(statement.subject, statement.predicate, statement.object, statement.why)) + callback('', true) + } + } + + const basicOps = { + create: [], + update: [{ + sourceNode: subject.value, + name: '', + nickname: '', + pronouns: '', + dateOfBirth: '', + imageSrc: 'https://example.com/current.png', + status: 'updated' + }], + remove: [] + } + + await processHeadingMutations(store, subject, { + basicOps, + phoneOps: { create: [], update: [], remove: [] }, + emailOps: { create: [], update: [], remove: [] }, + addressOps: { create: [], update: [], remove: [] } + } as any) + + expect(store.any(subject, ns.foaf('img'), null, doc)).toBeNull() + expect(store.any(subject, ns.vcard('hasPhoto'), null, doc)?.value).toBe('https://example.com/current.png') + expect(getHeading(subject, store).imageSrc).toBe('https://example.com/current.png') + + await processHeadingMutations(store, subject, { + basicOps: { + create: [], + update: [], + remove: [{ + sourceNode: subject.value, + name: '', + nickname: '', + pronouns: '', + dateOfBirth: '', + imageSrc: '', + status: 'deleted' + }] + }, + phoneOps: { create: [], update: [], remove: [] }, + emailOps: { create: [], update: [], remove: [] }, + addressOps: { create: [], update: [], remove: [] } + } as any) + + expect(getHeading(subject, store).imageSrc).toBeUndefined() + }) + +}) diff --git a/test/profile-heading-presenter.test.ts b/test/profile-heading-presenter.test.ts new file mode 100644 index 00000000..7c2eed24 --- /dev/null +++ b/test/profile-heading-presenter.test.ts @@ -0,0 +1,82 @@ +import { beforeEach, describe, expect, it } from 'vitest' +import { getHeading } from '../src/profile/sections/heading/selectors' +import { blankNode, literal, sym } from 'rdflib' +import { ns } from '../src/util/ns' +import { store } from '../src/index' + +describe('presenter', () => { + const jane = sym('https://jane.doe.example/profile/card#me') + const doc = jane.doc() + + beforeEach(() => { + store.removeDocument(doc) + }) + + it('presents minimum available info', () => { + const result = getHeading(jane, store) + expect(result.name).toBe('card') + expect(result.imageSrc).toBeUndefined() + expect(result.jobTitle).toBeUndefined() + expect(result.location).toBeUndefined() + }) + + it('presents a name', () => { + store.add(jane, ns.foaf('name'), 'Jane Doe', doc) + const result = getHeading(jane, store) + expect(result.name).toBe('Jane Doe') + }) + + it('presents an image', () => { + store.add( + jane, + ns.foaf('img'), + sym('https://jane.doe.example/profile/me.jpg'), + doc + ) + const result = getHeading(jane, store) + expect(result.imageSrc).toBe('https://jane.doe.example/profile/me.jpg') + }) + + it('presents current resume role in jobTitle', () => { + const currentRole = sym('https://jane.doe.example/profile/card#role-current') + store.add(currentRole, ns.org('member'), jane, doc) + store.add(currentRole, ns.rdf('type'), ns.solid('CurrentRole'), doc) + store.add(currentRole, ns.vcard('role'), literal('Test Double'), doc) + const result = getHeading(jane, store) + expect(result.jobTitle).toBe('Test Double') + }) + + it('presents country in location', () => { + const address = blankNode() + store.add(jane, ns.vcard('hasAddress'), address, doc) + store.add(address, ns.vcard('country-name'), 'Germany', doc) + const result = getHeading(jane, store) + expect(result.location).toBe('Germany') + }) + + it('presents locality in location', () => { + const address = blankNode() + store.add(jane, ns.vcard('hasAddress'), address, doc) + store.add(address, ns.vcard('locality'), 'Hamburg', doc) + const result = getHeading(jane, store) + expect(result.location).toBe('Hamburg') + }) + + it('presents both locality and country name in location', () => { + const address = blankNode() + store.add(jane, ns.vcard('hasAddress'), address, doc) + store.add(address, ns.vcard('locality'), 'Hamburg', doc) + store.add(address, ns.vcard('country-name'), 'Germany', doc) + const result = getHeading(jane, store) + expect(result.location).toBe('Hamburg, Germany') + }) + + it('presents preferred Pronouns', () => { + store.add(jane, ns.solid('preferredSubjectPronoun'), 'they', doc) + store.add(jane, ns.solid('preferredObjectPronoun'), 'them', doc) + store.add(jane, ns.solid('preferredRelativePronoun'), 'their', doc) + const result = getHeading(jane, store) + expect(result.pronouns).toBe('they/them') + }) + +}) diff --git a/test/profile-languages-mutations.test.ts b/test/profile-languages-mutations.test.ts new file mode 100644 index 00000000..8a686f55 --- /dev/null +++ b/test/profile-languages-mutations.test.ts @@ -0,0 +1,362 @@ +import { describe, expect, it } from 'vitest' +import { Collection, graph, literal, NamedNode, st, sym } from 'rdflib' +import { ns } from '../src/util/ns' +import { presentLanguages } from '../src/profile/sections/languages/selectors' +import { processLanguageMutations } from '../src/profile/sections/languages/mutations' +import { saveLanguageUpdatesFailedMessageText } from '../src/profile/texts' +import { expandRdfList } from '../src/profile/sections/shared/rdfList' +describe('Languages selectors and mutations', () => { + it('selector returns empty list from empty store', () => { + const store = graph() as any + const subject = sym('https://example.com/profile/card#me') + + expect(presentLanguages(subject, store)).toEqual([]) + }) + + it('mutation surfaces updater callback failures', async () => { + const store = graph() as any + const subject = sym('https://example.com/profile/card#me') + store.updater = { + update: (_deletions: any[], _insertions: any[], callback: Function) => callback('', false, 'boom') + } + + const plan = { + create: [{ name: 'French', publicId: 'https://www.w3.org/ns/iana/language-code/fr', proficiency: '', sourceNode: '', status: 'new' }], + update: [], + remove: [] + } + await expect(processLanguageMutations(store, subject, plan as any)).rejects.toMatchObject({ + message: saveLanguageUpdatesFailedMessageText, + cause: expect.objectContaining({ message: 'boom' }) + }) + }) + + it('reads canonical id-node list with solid publicId URI and schema name', () => { + const store = graph() as any + const subject = sym('https://example.com/profile/card#me') + const doc = subject.doc() + const id1 = sym('https://example.com/profile/card#id1') + const id2 = sym('https://example.com/profile/card#id2') + store.add(subject, ns.schema('knowsLanguage'), new Collection([ + id1, + id2 + ]), doc) + store.add(id1, ns.solid('publicId'), sym('https://www.w3.org/ns/iana/language-code/en'), doc) + store.add(id1, ns.schema('name'), literal('English'), doc) + store.add(id2, ns.solid('publicId'), sym('https://www.w3.org/ns/iana/language-code/es'), doc) + store.add(id2, ns.schema('name'), literal('Spanish'), doc) + + const languages = presentLanguages(subject, store) + expect(languages).toHaveLength(2) + expect(languages.map((item) => item.name)).toEqual(['English', 'Spanish']) + expect(languages.map((item) => item.publicId)).toEqual([ + 'https://www.w3.org/ns/iana/language-code/en', + 'https://www.w3.org/ns/iana/language-code/es' + ]) + }) + + it('reads direct IANA URI list entries', () => { + const store = graph() as any + const subject = sym('https://example.com/profile/card#me') + const doc = subject.doc() + store.add(subject, ns.schema('knowsLanguage'), new Collection([ + sym('https://www.w3.org/ns/iana/language-code/en'), + sym('https://www.w3.org/ns/iana/language-code/es') + ]), doc) + + const languages = presentLanguages(subject, store) + expect(languages).toHaveLength(2) + expect(languages.map((item) => item.publicId)).toEqual([ + 'https://www.w3.org/ns/iana/language-code/en', + 'https://www.w3.org/ns/iana/language-code/es' + ]) + }) + + it('reads legacy entry-node list with solid:publicId literals', () => { + const store = graph() as any + const subject = sym('https://example.com/profile/card#me') + const doc = subject.doc() + const language1 = store.bnode() + const language2 = store.bnode() + + store.add(subject, ns.schema('knowsLanguage'), new Collection([language1, language2]), doc) + store.add(language1, ns.solid('publicId'), literal('fr'), doc) + store.add(language2, ns.solid('publicId'), literal('de'), doc) + + const languages = presentLanguages(subject, store) + expect(languages).toHaveLength(2) + expect(languages.map((item) => item.publicId)).toEqual(['fr', 'de']) + }) + + it('reads cumulative legacy list snapshots and keeps merged ordered languages', () => { + const store = graph() as any + const subject = sym('https://example.com/profile/card#me') + const doc = subject.doc() + const id1 = sym('https://example.com/profile/card#id1') + const id2 = sym('https://example.com/profile/card#id2') + const id3 = sym('https://example.com/profile/card#id3') + + store.add(subject, ns.schema('knowsLanguage'), new Collection([id1]), doc) + store.add(subject, ns.schema('knowsLanguage'), new Collection([id1, id2]), doc) + store.add(subject, ns.schema('knowsLanguage'), new Collection([id1, id2, id3]), doc) + + store.add(id1, ns.solid('publicId'), sym('https://www.w3.org/ns/iana/language-code/en'), doc) + store.add(id2, ns.solid('publicId'), sym('https://www.w3.org/ns/iana/language-code/fr'), doc) + store.add(id3, ns.solid('publicId'), sym('https://www.w3.org/ns/iana/language-code/de'), doc) + + const languages = presentLanguages(subject, store) + expect(languages).toHaveLength(3) + expect(languages.map((item) => item.publicId)).toEqual([ + 'https://www.w3.org/ns/iana/language-code/en', + 'https://www.w3.org/ns/iana/language-code/fr', + 'https://www.w3.org/ns/iana/language-code/de' + ]) + }) + + it('merges fragmented knowsLanguage rdf lists before deduping', () => { + const store = graph() as any + const subject = sym('https://example.com/profile/card#me') + const doc = subject.doc() + const id1 = sym('https://example.com/profile/card#id1') + const id2 = sym('https://example.com/profile/card#id2') + const id3 = sym('https://example.com/profile/card#id3') + + store.add(subject, ns.schema('knowsLanguage'), new Collection([id1, id2]), doc) + store.add(subject, ns.schema('knowsLanguage'), new Collection([id2, id3]), doc) + + store.add(id1, ns.solid('publicId'), sym('https://www.w3.org/ns/iana/language-code/en'), doc) + store.add(id2, ns.solid('publicId'), sym('https://www.w3.org/ns/iana/language-code/fr'), doc) + store.add(id3, ns.solid('publicId'), sym('https://www.w3.org/ns/iana/language-code/de'), doc) + + const languages = presentLanguages(subject, store) + + expect(languages).toHaveLength(3) + expect(languages.map((item) => item.publicId)).toEqual([ + 'https://www.w3.org/ns/iana/language-code/en', + 'https://www.w3.org/ns/iana/language-code/fr', + 'https://www.w3.org/ns/iana/language-code/de' + ]) + }) + + it('writes canonical id-node Collection model and removes legacy entry-node model statements', async () => { + const store = graph() as any + const subject = sym('https://example.com/profile/card#me') + const doc = subject.doc() + const legacyEntry1 = sym('https://example.com/profile/card#id111') + const legacyEntry2 = sym('https://example.com/profile/card#id222') + + store.add(subject, ns.schema('knowsLanguage'), new Collection([legacyEntry1]), doc) + store.add(subject, ns.schema('knowsLanguage'), new Collection([legacyEntry1, legacyEntry2]), doc) + store.add(legacyEntry1, ns.solid('publicId'), literal('fr'), doc) + store.add(legacyEntry2, ns.solid('publicId'), sym('https://www.w3.org/ns/iana/language-code/de'), doc) + store.add(legacyEntry1, ns.rdf('type'), ns.schema('Language'), doc) + + let insertionsCaptured: any[] = [] + store.updater = { + update: (deletions: any[], insertions: any[], callback: Function) => { + insertionsCaptured = insertions + deletions.forEach((statement) => store.remove(st(statement.subject, statement.predicate, statement.object, statement.why))) + insertions.forEach((statement) => store.add(statement.subject, statement.predicate, statement.object, statement.why)) + callback('', true) + } + } + + await processLanguageMutations( + store, + subject, + { create: [], update: [], remove: [] } as any, + [ + { name: 'German', publicId: 'https://www.w3.org/ns/iana/language-code/de', proficiency: '', sourceNode: '', status: 'existing' }, + { name: 'French', publicId: 'fr', proficiency: '', sourceNode: '', status: 'existing' } + ] as any + ) + + const knowsLanguageValues = store.statementsMatching(subject, ns.schema('knowsLanguage'), null, doc) + expect(knowsLanguageValues).toHaveLength(1) + const listValue = knowsLanguageValues[0].object as NamedNode + expect(listValue.termType).toBe('NamedNode') + expect(listValue.value).toContain('#id') + + const listElements = expandRdfList(store, listValue) + expect(listElements.every((node: any) => node?.termType === 'NamedNode')).toBe(true) + expect(listElements.every((node: any) => node?.value.includes('#id'))).toBe(true) + + const publicIdStatements = listElements.map((node: any) => { + return store.any(node, ns.solid('publicId'), null, doc) + }) + + expect(publicIdStatements.map((node: any) => node?.value)).toEqual([ + 'https://www.w3.org/ns/iana/language-code/de', + 'https://www.w3.org/ns/iana/language-code/fr' + ]) + + const nameStatements = publicIdStatements.map((node: any) => { + return store.any(node, ns.schema('name'), null, doc) + }) + expect(nameStatements.map((node: any) => node?.value)).toEqual(['German', 'French']) + expect(nameStatements.map((node: any) => node?.lang)).toEqual(['en', 'en']) + + expect(insertionsCaptured.some((statement) => statement.predicate.value === ns.schema('knowsLanguage').value)).toBe(true) + + const legacyPublicIdLiteralStatements = store.statementsMatching(undefined, ns.solid('publicId'), literal('fr'), doc) + expect(legacyPublicIdLiteralStatements).toHaveLength(0) + expect(store.statementsMatching(legacyEntry1, ns.rdf('type'), ns.schema('Language'), doc)).toHaveLength(0) + }) + + it('returns reordered languages from the selector after saving orderedRows', async () => { + const store = graph() as any + const subject = sym('https://example.com/profile/card#me') + const doc = subject.doc() + const englishEntry = sym('https://example.com/profile/card#id-en') + const frenchEntry = sym('https://example.com/profile/card#id-fr') + + store.add(subject, ns.schema('knowsLanguage'), new Collection([englishEntry, frenchEntry]), doc) + store.add(englishEntry, ns.solid('publicId'), sym('https://www.w3.org/ns/iana/language-code/en'), doc) + store.add(sym('https://www.w3.org/ns/iana/language-code/en'), ns.schema('name'), literal('English', 'en'), doc) + store.add(frenchEntry, ns.solid('publicId'), sym('https://www.w3.org/ns/iana/language-code/fr'), doc) + store.add(sym('https://www.w3.org/ns/iana/language-code/fr'), ns.schema('name'), literal('French', 'en'), doc) + + store.updater = { + update: (deletions: any[], insertions: any[], callback: Function) => { + deletions.forEach((statement) => store.remove(st(statement.subject, statement.predicate, statement.object, statement.why))) + insertions.forEach((statement) => store.add(statement.subject, statement.predicate, statement.object, statement.why)) + callback('', true) + } + } + + await processLanguageMutations( + store, + subject, + { create: [], update: [], remove: [] } as any, + [ + { name: 'French', publicId: 'https://www.w3.org/ns/iana/language-code/fr', proficiency: '', sourceNode: frenchEntry.value, status: 'existing' }, + { name: 'English', publicId: 'https://www.w3.org/ns/iana/language-code/en', proficiency: '', sourceNode: englishEntry.value, status: 'existing' } + ] as any + ) + + const languages = presentLanguages(subject, store) + + expect(languages.map((item) => item.publicId)).toEqual([ + 'https://www.w3.org/ns/iana/language-code/fr', + 'https://www.w3.org/ns/iana/language-code/en' + ]) + expect(languages.map((item) => item.name)).toEqual(['French', 'English']) + }) + + it('preserves existing language entry nodes and unrelated statements on reorder', async () => { + const store = graph() as any + const subject = sym('https://example.com/profile/card#me') + const doc = subject.doc() + const englishEntry = sym('https://example.com/profile/card#id-en') + const frenchEntry = sym('https://example.com/profile/card#id-fr') + + store.add(subject, ns.schema('knowsLanguage'), new Collection([englishEntry, frenchEntry]), doc) + store.add(englishEntry, ns.solid('publicId'), sym('https://www.w3.org/ns/iana/language-code/en'), doc) + store.add(sym('https://www.w3.org/ns/iana/language-code/en'), ns.schema('name'), literal('English', 'en'), doc) + store.add(frenchEntry, ns.solid('publicId'), sym('https://www.w3.org/ns/iana/language-code/fr'), doc) + store.add(sym('https://www.w3.org/ns/iana/language-code/fr'), ns.schema('name'), literal('French', 'en'), doc) + store.add(englishEntry, ns.rdf('type'), ns.schema('Language'), doc) + + store.updater = { + update: (deletions: any[], insertions: any[], callback: Function) => { + deletions.forEach((statement) => store.remove(st(statement.subject, statement.predicate, statement.object, statement.why))) + insertions.forEach((statement) => store.add(statement.subject, statement.predicate, statement.object, statement.why)) + callback('', true) + } + } + + await processLanguageMutations( + store, + subject, + { create: [], update: [], remove: [] } as any, + [ + { name: 'French', publicId: 'https://www.w3.org/ns/iana/language-code/fr', proficiency: '', sourceNode: frenchEntry.value, status: 'existing' }, + { name: 'English', publicId: 'https://www.w3.org/ns/iana/language-code/en', proficiency: '', sourceNode: englishEntry.value, status: 'existing' } + ] as any + ) + + const languages = presentLanguages(subject, store) + + expect(languages.map((item) => item.sourceNode.value)).toEqual([ + frenchEntry.value, + englishEntry.value + ]) + expect(store.statementsMatching(englishEntry, ns.rdf('type'), ns.schema('Language'), doc)).toHaveLength(1) + }) + + it('preserves stored schema:name triples on publicId nodes on reorder', async () => { + const store = graph() as any + const subject = sym('https://example.com/profile/card#me') + const doc = subject.doc() + const englishEntry = sym('https://example.com/profile/card#id-en') + const frenchEntry = sym('https://example.com/profile/card#id-fr') + const englishPublicId = sym('https://www.w3.org/ns/iana/language-code/en') + const frenchPublicId = sym('https://www.w3.org/ns/iana/language-code/fr') + + store.add(subject, ns.schema('knowsLanguage'), new Collection([englishEntry, frenchEntry]), doc) + store.add(englishEntry, ns.solid('publicId'), englishPublicId, doc) + store.add(englishPublicId, ns.schema('name'), literal('English', 'en'), doc) + store.add(frenchEntry, ns.solid('publicId'), frenchPublicId, doc) + store.add(frenchPublicId, ns.schema('name'), literal('French', 'en'), doc) + + store.updater = { + update: (deletions: any[], insertions: any[], callback: Function) => { + deletions.forEach((statement) => store.remove(st(statement.subject, statement.predicate, statement.object, statement.why))) + insertions.forEach((statement) => store.add(statement.subject, statement.predicate, statement.object, statement.why)) + callback('', true) + } + } + + await processLanguageMutations( + store, + subject, + { create: [], update: [], remove: [] } as any, + [ + { name: 'French', publicId: 'https://www.w3.org/ns/iana/language-code/fr', proficiency: '', sourceNode: frenchEntry.value, status: 'existing' }, + { name: 'English', publicId: 'https://www.w3.org/ns/iana/language-code/en', proficiency: '', sourceNode: englishEntry.value, status: 'existing' } + ] as any + ) + + expect(store.statementsMatching(englishPublicId, ns.schema('name'), null, doc)).toHaveLength(1) + expect(store.statementsMatching(frenchPublicId, ns.schema('name'), null, doc)).toHaveLength(1) + }) + + + + + + + it('removes schema:name on publicId node when language row is deleted', async () => { + const store = graph() as any + const subject = sym('https://example.com/profile/card#me') + const doc = subject.doc() + const languageEntry = sym('https://example.com/profile/card#id-es') + const publicIdNode = sym('https://www.w3.org/ns/iana/language-code/es') + + store.add(subject, ns.schema('knowsLanguage'), new Collection([languageEntry]), doc) + store.add(languageEntry, ns.solid('publicId'), publicIdNode, doc) + store.add(publicIdNode, ns.schema('name'), literal('Spanish', 'en'), doc) + + store.updater = { + update: (deletions: any[], insertions: any[], callback: Function) => { + deletions.forEach((statement) => store.remove(st(statement.subject, statement.predicate, statement.object, statement.why))) + insertions.forEach((statement) => store.add(statement.subject, statement.predicate, statement.object, statement.why)) + callback('', true) + } + } + + await processLanguageMutations( + store, + subject, + { + create: [], + update: [], + remove: [{ name: 'Spanish', publicId: 'es', proficiency: '', sourceNode: languageEntry.value, status: 'deleted' }] + } as any, + [] + ) + + expect(store.statementsMatching(subject, ns.schema('knowsLanguage'), null, doc)).toHaveLength(0) + expect(store.statementsMatching(publicIdNode, ns.schema('name'), null, doc)).toHaveLength(0) + }) +}) diff --git a/test/profile-projects-mutations.test.ts b/test/profile-projects-mutations.test.ts new file mode 100644 index 00000000..39c48644 --- /dev/null +++ b/test/profile-projects-mutations.test.ts @@ -0,0 +1,215 @@ +import { describe, expect, it } from 'vitest' +import { blankNode, graph, literal, st, sym } from 'rdflib' +import { ns } from '../src/util/ns' +import { presentProjects } from '../src/profile/sections/projects/selectors' +import { processProjectsMutations } from '../src/profile/sections/projects/mutations' +import { saveProjectsUpdatesFailedMessageText } from '../src/profile/texts' + +describe('Projects selectors and mutations', () => { + it('selector returns empty projects from empty store', async () => { + const store = graph() as any + const subject = sym('https://example.com/profile/card#me') + + await expect(presentProjects(subject, store)).resolves.toEqual([]) + }) + + it('mutation surfaces unsupported store updater errors', async () => { + const store = graph() as any + const subject = sym('https://example.com/profile/card#me') + + const plan = { + create: [{ url: 'https://example.org/project-a', sourceNode: '', status: 'new' }], + update: [], + remove: [] + } + await expect(processProjectsMutations(store, subject, plan as any)).rejects.toMatchObject({ + message: saveProjectsUpdatesFailedMessageText, + cause: expect.objectContaining({ + message: 'Project updates are not supported by this store updater.' + }) + }) + }) + + it('selector reads projects from solid:community links', async () => { + const store = graph() as any + const subject = sym('https://example.com/profile/card#me') + const doc = subject.doc() + + store.add(subject, ns.solid('community'), sym('https://example.org/community'), doc) + + const projects = await presentProjects(subject, store) + expect(projects).toHaveLength(1) + expect(projects[0].url).toBe('https://example.org/community') + }) + + it('selector reads projects from blank-node community entries with solid:publicId', async () => { + const store = graph() as any + const subject = sym('https://example.com/profile/card#me') + const doc = subject.doc() + const communityEntry = blankNode() + const linkedWebId = sym('https://example.org/project/profile/card#me') + + store.add(subject, ns.solid('community'), communityEntry, doc) + store.add(communityEntry, ns.solid('publicId'), linkedWebId, doc) + + const projects = await presentProjects(subject, store) + expect(projects).toHaveLength(1) + expect(projects[0].url).toBe('https://example.org/project/profile/card#me') + }) + + it('selector reads projects when solid:community is stored as an RDF list', async () => { + const store = graph() as any + const subject = sym('https://example.com/profile/card#me') + const doc = subject.doc() + const listHead = blankNode() + const listTail = blankNode() + const linkedWebId = sym('https://example.org/project-list-item#me') + + store.add(subject, ns.solid('community'), listHead, doc) + store.add(listHead, ns.rdf('first'), linkedWebId, doc) + store.add(listHead, ns.rdf('rest'), listTail, doc) + store.add(listTail, ns.rdf('first'), sym('https://example.org/ignored-second-item#me'), doc) + store.add(listTail, ns.rdf('rest'), ns.rdf('nil'), doc) + + const projects = await presentProjects(subject, store) + expect(projects.length).toBeGreaterThan(0) + expect(projects.some((project) => project.url === 'https://example.org/project-list-item#me')).toBe(true) + }) + + it('removes a project when solid:community is stored as an RDF list', async () => { + const store = graph() as any + const subject = sym('https://example.com/profile/card#me') + const doc = subject.doc() + const listHead = blankNode() + const listTail = blankNode() + const keepProject = sym('https://example.org/keep-project#me') + const removeProject = sym('https://example.org/remove-project#me') + + store.add(subject, ns.solid('community'), listHead, doc) + store.add(listHead, ns.rdf('first'), keepProject, doc) + store.add(listHead, ns.rdf('rest'), listTail, doc) + store.add(listTail, ns.rdf('first'), removeProject, doc) + store.add(listTail, ns.rdf('rest'), ns.rdf('nil'), doc) + + store.updater = { + update: (deletions: any[], insertions: any[], callback: Function) => { + deletions.forEach((statement) => store.remove(st(statement.subject, statement.predicate, statement.object, statement.why))) + insertions.forEach((statement) => store.add(statement.subject, statement.predicate, statement.object, statement.why)) + callback('', true) + } + } + + await processProjectsMutations(store, subject, { + create: [], + update: [], + remove: [{ url: removeProject.value, sourceNode: removeProject.value, status: 'deleted' }] + } as any) + + const links = store.statementsMatching(subject, ns.solid('community'), null, doc) + expect(links).toHaveLength(1) + expect(links[0].object.value).toBe(keepProject.value) + expect(store.statementsMatching(listHead, null, null, doc)).toHaveLength(0) + expect(store.statementsMatching(listTail, null, null, doc)).toHaveLength(0) + }) + + it('removes a blank-node community by its resolved project URL', async () => { + const store = graph() as any + const subject = sym('https://example.com/profile/card#me') + const doc = subject.doc() + const communityEntry = blankNode() + const projectUrl = sym('https://example.org/blank-project#me') + + store.add(subject, ns.solid('community'), communityEntry, doc) + store.add(communityEntry, ns.schema('url'), projectUrl, doc) + store.updater = { + update: (deletions: any[], insertions: any[], callback: Function) => { + deletions.forEach((statement) => store.remove(st(statement.subject, statement.predicate, statement.object, statement.why))) + insertions.forEach((statement) => store.add(statement.subject, statement.predicate, statement.object, statement.why)) + callback('', true) + } + } + + await processProjectsMutations(store, subject, { + create: [], + update: [], + remove: [{ + url: projectUrl.value, + sourceNode: communityEntry.value, + status: 'deleted' + }] + } as any) + + expect(store.statementsMatching(subject, ns.solid('community'), null, doc)).toHaveLength(0) + expect(store.statementsMatching(communityEntry, null, null, doc)).toHaveLength(0) + }) + + it('selector resolves literal URL values in community entries', async () => { + const store = graph() as any + const subject = sym('https://example.com/profile/card#me') + const doc = subject.doc() + + store.add(subject, ns.solid('community'), literal('https://example.org/literal-project#me'), doc) + + const projects = await presentProjects(subject, store) + expect(projects).toHaveLength(1) + expect(projects[0].url).toBe('https://example.org/literal-project#me') + }) + + it('mutation stores only solid:community links', async () => { + const store = graph() as any + const subject = sym('https://example.com/profile/card#me') + const doc = subject.doc() + + store.updater = { + update: (deletions: any[], insertions: any[], callback: Function) => { + deletions.forEach((statement) => store.remove(st(statement.subject, statement.predicate, statement.object, statement.why))) + insertions.forEach((statement) => store.add(statement.subject, statement.predicate, statement.object, statement.why)) + callback('', true) + } + } + + const plan = { + create: [{ url: 'https://example.org/project-a', sourceNode: '', status: 'new' }], + update: [], + remove: [] + } + + await processProjectsMutations(store, subject, plan as any) + + const links = store.statementsMatching(subject, ns.solid('community'), null, doc) + expect(links).toHaveLength(1) + expect(links[0].object.value).toBe('https://example.org/project-a') + expect(store.statementsMatching(subject, ns.schema('memberOf'), null, doc)).toHaveLength(0) + }) + + it('dedupes created project URLs after normalization', async () => { + const store = graph() as any + const subject = sym('https://example.com/profile/card#me') + const doc = subject.doc() + + store.updater = { + update: (deletions: any[], insertions: any[], callback: Function) => { + deletions.forEach((statement) => store.remove(st(statement.subject, statement.predicate, statement.object, statement.why))) + insertions.forEach((statement) => store.add(statement.subject, statement.predicate, statement.object, statement.why)) + callback('', true) + } + } + + await processProjectsMutations(store, subject, { + create: [ + { url: ' https://example.org/project-a#first ', sourceNode: '', status: 'new' }, + { url: 'https://example.org/project-a#second', sourceNode: '', status: 'new' }, + { url: 'not a url', sourceNode: '', status: 'new' } + ], + update: [], + remove: [] + } as any) + + const links = store.statementsMatching(subject, ns.solid('community'), null, doc) + expect(links).toHaveLength(1) + expect(links[0].object.value).toBe('https://example.org/project-a#first') + }) + + + +}) diff --git a/test/profile-rdf-list.test.ts b/test/profile-rdf-list.test.ts new file mode 100644 index 00000000..324addd0 --- /dev/null +++ b/test/profile-rdf-list.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from 'vitest' +import { graph, literal, st, sym } from 'rdflib' +import { expandRdfList } from '../src/profile/sections/shared/rdfList' +import { ns } from '../src/util/ns' + +describe('RDF list expansion', () => { + it('stops at a self-referential list node', () => { + const store = graph() + const head = sym('https://example.com/profile/card#list') + const value = literal('first') + + store.add(st(head, ns.rdf('first'), value)) + store.add(st(head, ns.rdf('rest'), head)) + + expect(expandRdfList(store, head)).toEqual([value]) + }) + + it('stops when a list cycle returns to an earlier node', () => { + const store = graph() + const first = sym('https://example.com/profile/card#first') + const second = sym('https://example.com/profile/card#second') + const firstValue = literal('first') + const secondValue = literal('second') + + store.add(st(first, ns.rdf('first'), firstValue)) + store.add(st(first, ns.rdf('rest'), second)) + store.add(st(second, ns.rdf('first'), secondValue)) + store.add(st(second, ns.rdf('rest'), first)) + + expect(expandRdfList(store, first)).toEqual([firstValue, secondValue]) + }) +}) diff --git a/test/profile-resume-mutations.test.ts b/test/profile-resume-mutations.test.ts new file mode 100644 index 00000000..8b5ca814 --- /dev/null +++ b/test/profile-resume-mutations.test.ts @@ -0,0 +1,376 @@ +import { describe, expect, it } from 'vitest' +import { blankNode, graph, literal, st, sym } from 'rdflib' +import { ns } from '../src/util/ns' +import { presentCV } from '../src/profile/sections/resume/selectors' +import { processResumeMutations } from '../src/profile/sections/resume/mutations' +import { saveResumeUpdatesFailedMessageText, updaterUnsupportedStoreErrorMessageText } from '../src/profile/texts' + +describe('Resume selectors and mutations', () => { + it('selector returns empty roles from empty store', () => { + const store = graph() as any + const subject = sym('https://example.com/profile/card#me') + + expect(presentCV(subject, store)).toEqual([]) + }) + + it('mutation surfaces unsupported store updater errors', async () => { + const store = graph() as any + const subject = sym('https://example.com/profile/card#me') + + const plan = { create: [], update: [], remove: [] } + let thrownError: any + + try { + await processResumeMutations(store, subject, plan as any) + } catch (error) { + thrownError = error + } + + expect(thrownError).toBeInstanceOf(Error) + expect(thrownError?.message).toBe(saveResumeUpdatesFailedMessageText) + expect(thrownError?.cause).toBeInstanceOf(Error) + expect(thrownError?.cause?.message).toBe(updaterUnsupportedStoreErrorMessageText) + }) + + it('creates resume membership and organization nodes with #id + 13 digits', async () => { + const store = graph() as any + const subject = sym('https://example.com/profile/card#me') + const insertionsCaptured: any[] = [] + + store.updater = { + update: (_deletions: any[], insertions: any[], callback: Function) => { + insertionsCaptured.push(...insertions) + callback('', true) + } + } + + const plan = { + create: [{ + title: 'Engineer', + startDate: undefined, + endDate: undefined, + isCurrentRole: true, + orgName: 'Acme Corp', + orgType: 'Corporation', + orgLocation: 'Remote', + orgHomePage: 'https://acme.example', + description: 'Build things', + entryNode: '', + status: 'new' + }], + update: [], + remove: [] + } + + await processResumeMutations(store, subject, plan as any) + + const idPattern = /^https:\/\/example\.com\/profile\/card#id\d{13}$/ + const membershipNode = insertionsCaptured.find((statement) => statement.predicate.value === ns.org('member').value)?.subject + + expect(membershipNode?.value).toMatch(idPattern) + + const organizationNode = insertionsCaptured.find( + (statement) => statement.subject.value === membershipNode.value && statement.predicate.value === ns.org('organization').value + )?.object + + expect(organizationNode?.value).toMatch(idPattern) + + const organizationTypeValues = insertionsCaptured + .filter((statement) => + statement.subject.value === organizationNode.value && + statement.predicate.value === ns.rdf('type').value + ) + .map((statement) => statement.object.value) + + expect(organizationTypeValues).toContain(ns.vcard('Organization').value) + expect(organizationTypeValues).toContain(ns.schema('Corporation').value) + expect(insertionsCaptured.some((statement) => + statement.subject.value === organizationNode.value && + statement.predicate.value === ns.org('classification').value + )).toBe(false) + }) + + it('writes solid role class type consistent with role timing', async () => { + const store = graph() as any + const subject = sym('https://example.com/profile/card#me') + const insertionsCaptured: any[] = [] + + store.updater = { + update: (_deletions: any[], insertions: any[], callback: Function) => { + insertionsCaptured.push(...insertions) + callback('', true) + } + } + + const plan = { + create: [ + { + title: 'Current Role', + startDate: literal('2024-01-01'), + endDate: undefined, + isCurrentRole: true, + orgName: 'Now Inc', + entryNode: '', + status: 'new' + }, + { + title: 'Past Role', + startDate: literal('2019-01-01'), + endDate: literal('2020-01-01'), + isCurrentRole: false, + orgName: 'Old Inc', + entryNode: '', + status: 'new' + }, + { + title: 'Future Role', + startDate: literal('2099-01-01'), + endDate: undefined, + isCurrentRole: false, + orgName: 'Future Inc', + entryNode: '', + status: 'new' + } + ], + update: [], + remove: [] + } + + await processResumeMutations(store, subject, plan as any) + + const typeStatements = insertionsCaptured.filter( + (statement) => statement.predicate.value === ns.rdf('type').value + ) + const typeValues = typeStatements.map((statement) => statement.object.value) + + expect(typeValues).toContain(ns.solid('CurrentRole').value) + expect(typeValues).toContain(ns.solid('PastRole').value) + expect(typeValues).toContain(ns.solid('FutureRole').value) + + const dateStatements = insertionsCaptured.filter( + (statement) => statement.predicate.value === ns.schema('startDate').value || statement.predicate.value === ns.schema('endDate').value + ) + expect(dateStatements.length).toBeGreaterThan(0) + expect(dateStatements.every((statement) => statement.object.datatype?.value === ns.xsd('date').value)).toBe(true) + }) + + it('selector derives orgType from linked organization node type', () => { + const store = graph() as any + const subject = sym('https://example.com/profile/card#me') + const doc = subject.doc() + + const membership = sym('https://example.com/profile/card#id123') + const organization = sym('https://example.com/profile/card#id456') + + store.add(membership, ns.org('member'), subject, doc) + store.add(membership, ns.rdf('type'), ns.solid('PastRole'), doc) + store.add(membership, ns.vcard('role'), literal('Software Engineer'), doc) + store.add(membership, ns.org('organization'), organization, doc) + store.add(membership, ns.schema('startDate'), literal('2000-03-12'), doc) + store.add(membership, ns.schema('endDate'), literal('2004-04-12'), doc) + store.add(organization, ns.rdf('type'), ns.schema('Corporation'), doc) + store.add(organization, ns.schema('name'), literal('Inrupt'), doc) + + const roles = presentCV(subject, store) + expect(roles).toHaveLength(1) + expect(roles[0].orgType).toBe('Corporation') + expect(roles[0].roleType).toBe('PastRole') + }) + + it('selector includes linked organization solid publicId', () => { + const store = graph() as any + const subject = sym('https://example.com/profile/card#me') + const doc = subject.doc() + + const membership = sym('https://example.com/profile/card#id123') + const organization = sym('https://example.com/profile/card#id456') + const publicId = sym('https://example.com/catalog/org/nasa') + + store.add(membership, ns.org('member'), subject, doc) + store.add(membership, ns.rdf('type'), ns.solid('PastRole'), doc) + store.add(membership, ns.vcard('role'), literal('Engineer'), doc) + store.add(membership, ns.org('organization'), organization, doc) + store.add(organization, ns.schema('name'), literal('NASA'), doc) + store.add(organization, ns.solid('publicId'), publicId, doc) + + const roles = presentCV(subject, store) + expect(roles).toHaveLength(1) + expect(roles[0].orgPublicId).toBe(publicId.value) + }) + + it('does not write role classes as organization rdf:type', async () => { + const store = graph() as any + const subject = sym('https://example.com/profile/card#me') + const insertionsCaptured: any[] = [] + + store.updater = { + update: (_deletions: any[], insertions: any[], callback: Function) => { + insertionsCaptured.push(...insertions) + callback('', true) + } + } + + const plan = { + create: [{ + title: 'Engineer', + startDate: literal('2000-03-12'), + endDate: literal('2004-04-12'), + isCurrentRole: false, + orgName: 'Inrupt', + orgType: 'PastRole', + entryNode: '', + status: 'new' + }], + update: [], + remove: [] + } + + await processResumeMutations(store, subject, plan as any) + + const organizationLink = insertionsCaptured.find( + (statement) => statement.predicate.value === ns.org('organization').value + ) + expect(organizationLink).toBeTruthy() + + const organizationTypeValues = insertionsCaptured + .filter((statement) => + statement.subject.value === organizationLink.object.value && + statement.predicate.value === ns.rdf('type').value + ) + .map((statement) => statement.object.value) + + expect(organizationTypeValues).toContain(ns.vcard('Organization').value) + expect(organizationTypeValues).not.toContain(ns.schema('PastRole').value) + expect(organizationTypeValues).not.toContain(ns.solid('PastRole').value) + }) + + it('writes organization solid publicId when provided', async () => { + const store = graph() as any + const subject = sym('https://example.com/profile/card#me') + const insertionsCaptured: any[] = [] + + store.updater = { + update: (_deletions: any[], insertions: any[], callback: Function) => { + insertionsCaptured.push(...insertions) + callback('', true) + } + } + + const plan = { + create: [{ + title: 'Engineer', + startDate: undefined, + endDate: undefined, + isCurrentRole: true, + orgName: '', + orgPublicId: 'https://example.com/catalog/org/nasa', + orgType: '', + orgLocation: '', + orgHomePage: '', + description: '', + entryNode: '', + status: 'new' + }], + update: [], + remove: [] + } + + await processResumeMutations(store, subject, plan as any) + + const organizationLink = insertionsCaptured.find( + (statement) => statement.predicate.value === ns.org('organization').value + ) + expect(organizationLink).toBeTruthy() + + const publicIdStatement = insertionsCaptured.find( + (statement) => + statement.subject.value === organizationLink.object.value && + statement.predicate.value === ns.solid('publicId').value + ) + + expect(publicIdStatement?.object.value).toBe('https://example.com/catalog/org/nasa') + }) + + it('removes stale blank-node membership and organization statements during update', async () => { + const store = graph() as any + const subject = sym('https://example.com/profile/card#me') + const doc = subject.doc() + const oldMembership = blankNode() + const oldOrganization = blankNode() + + store.add(oldMembership, ns.org('member'), subject, doc) + store.add(oldMembership, ns.rdf('type'), ns.solid('PastRole'), doc) + store.add(oldMembership, ns.vcard('role'), literal('Old role'), doc) + store.add(oldMembership, ns.org('organization'), oldOrganization, doc) + store.add(oldOrganization, ns.schema('name'), literal('Old organization'), doc) + store.updater = { + update: (deletions: any[], insertions: any[], callback: Function) => { + deletions.forEach((statement) => store.remove(st(statement.subject, statement.predicate, statement.object, statement.why))) + insertions.forEach((statement) => store.add(statement.subject, statement.predicate, statement.object, statement.why)) + callback('', true) + } + } + + await processResumeMutations(store, subject, { + create: [], + update: [{ + title: 'New role', + roleType: 'CurrentRole', + isCurrentRole: true, + orgName: 'New organization', + sourceNode: oldMembership.value, + status: 'updated' + }], + remove: [] + } as any) + + expect(store.statementsMatching(oldMembership, null, null, doc)).toHaveLength(0) + expect(store.statementsMatching(oldOrganization, null, null, doc)).toHaveLength(0) + const memberships = store.each(null, ns.org('member'), subject, doc) + expect(memberships).toHaveLength(1) + expect(memberships[0].termType).toBe('NamedNode') + expect(store.any(memberships[0], ns.vcard('role'), null, doc)?.value).toBe('New role') + }) + + + + it('uses explicit roleType when persisting membership type', async () => { + const store = graph() as any + const subject = sym('https://example.com/profile/card#me') + const insertionsCaptured: any[] = [] + + store.updater = { + update: (_deletions: any[], insertions: any[], callback: Function) => { + insertionsCaptured.push(...insertions) + callback('', true) + } + } + + const plan = { + create: [{ + title: 'Software Engineer', + roleType: 'PastRole', + startDate: literal('2099-01-01'), + endDate: undefined, + isCurrentRole: false, + orgName: 'Inrupt', + orgType: 'Corporation', + entryNode: '', + status: 'new' + }], + update: [], + remove: [] + } + + await processResumeMutations(store, subject, plan as any) + + const membershipTypeValues = insertionsCaptured + .filter((statement) => statement.predicate.value === ns.rdf('type').value) + .map((statement) => statement.object.value) + + expect(membershipTypeValues).toContain(ns.solid('PastRole').value) + expect(membershipTypeValues).not.toContain(ns.solid('FutureRole').value) + }) + + +}) diff --git a/test/profile-skills-mutations.test.ts b/test/profile-skills-mutations.test.ts new file mode 100644 index 00000000..a08e0416 --- /dev/null +++ b/test/profile-skills-mutations.test.ts @@ -0,0 +1,169 @@ +import { describe, expect, it } from 'vitest' +import { graph, st, sym, literal } from 'rdflib' +import { ns } from '../src/util/ns' +import { getSkillDetails, getSkills } from '../src/profile/sections/skills/selectors' +import { processSkillsMutations } from '../src/profile/sections/skills/mutations' +import { saveSkillsUpdatesFailedMessageText } from '../src/profile/texts' + +describe('Skills selectors and mutations', () => { + it('selector returns empty skills from empty store', () => { + const store = graph() as any + const subject = sym('https://example.com/profile/card#me') + + expect(getSkills(subject, store)).toEqual([]) + }) + + it('selector reads names from publicId node when publicId is a skill: node', () => { + const store = graph() as any + const subject = sym('https://example.com/profile/card#me') + const doc = subject.doc() + const skillEntry = sym(`${doc.value}#id1`) + const skillPublicId = sym('skill:typescript') + + store.add(subject, ns.schema('skills'), skillEntry, doc) + store.add(skillEntry, ns.solid('publicId'), skillPublicId, doc) + store.add(skillPublicId, ns.schema('name'), 'TypeScript', doc) + + expect(getSkills(subject, store)).toEqual(['TypeScript']) + }) + + it('selector reads names from blank-node publicId and preserves blank-node identifier', () => { + const store = graph() as any + const subject = sym('https://example.com/profile/card#me') + const doc = subject.doc() + const skillEntry = sym(`${doc.value}#id1`) + const skillPublicId = store.bnode('custom-skill') + + store.add(subject, ns.schema('skills'), skillEntry, doc) + store.add(skillEntry, ns.solid('publicId'), skillPublicId, doc) + store.add(skillPublicId, ns.schema('name'), 'Facilitation', doc) + + expect(getSkills(subject, store)).toEqual(['Facilitation']) + expect(getSkillDetails(subject, store)).toEqual([ + expect.objectContaining({ + name: 'Facilitation', + publicId: '_:custom-skill' + }) + ]) + }) + + it('mutation surfaces section-specific unsupported store updater errors', async () => { + const store = graph() as any + const subject = sym('https://example.com/profile/card#me') + + const plan = { + create: [{ name: 'TypeScript', publicId: 'skill:typescript', sourceNode: '', status: 'new' }], + update: [], + remove: [] + } + await expect(processSkillsMutations(store, subject, plan as any)).rejects.toMatchObject({ + message: saveSkillsUpdatesFailedMessageText, + cause: expect.objectContaining({ + message: 'Skill updates are not supported by this store updater.' + }) + }) + }) + + it('writes created skills with skill: node publicId and name on the publicId node', async () => { + const store = graph() as any + const subject = sym('https://example.com/profile/card#me') + const doc = subject.doc() + + store.updater = { + update: (deletions: any[], insertions: any[], callback: Function) => { + deletions.forEach((statement) => store.remove(st(statement.subject, statement.predicate, statement.object, statement.why))) + insertions.forEach((statement) => store.add(statement.subject, statement.predicate, statement.object, statement.why)) + callback('', true) + } + } + + const plan = { + create: [{ name: 'TypeScript', publicId: 'http://data.europa.eu/esco/skill/typescript', sourceNode: '', status: 'new' }], + update: [], + remove: [] + } + + await processSkillsMutations(store, subject, plan as any) + + const skillLinks = store.statementsMatching(subject, ns.schema('skills'), null, doc) + expect(skillLinks).toHaveLength(1) + + const sourceNode = skillLinks[0].object + const publicIdLink = store.any(sourceNode, ns.solid('publicId'), null, doc) + expect(publicIdLink?.termType).toBe('NamedNode') + expect(publicIdLink?.value).toBe('skill:typescript') + + const skillName = store.any(publicIdLink as any, ns.schema('name'), null, doc) + expect(skillName?.value).toBe('TypeScript') + + const legacyNameOnEntry = store.any(sourceNode, ns.schema('name'), null, doc) + expect(legacyNameOnEntry).toBeNull() + }) + + it('writes created custom skills with blank-node publicId and name on that blank node', async () => { + const store = graph() as any + const subject = sym('https://example.com/profile/card#me') + const doc = subject.doc() + + store.updater = { + update: (deletions: any[], insertions: any[], callback: Function) => { + deletions.forEach((statement) => store.remove(st(statement.subject, statement.predicate, statement.object, statement.why))) + insertions.forEach((statement) => store.add(statement.subject, statement.predicate, statement.object, statement.why)) + callback('', true) + } + } + + const plan = { + create: [{ name: 'Facilitation', publicId: '', sourceNode: '', status: 'new' }], + update: [], + remove: [] + } + + await processSkillsMutations(store, subject, plan as any) + + const skillLinks = store.statementsMatching(subject, ns.schema('skills'), null, doc) + expect(skillLinks).toHaveLength(1) + + const sourceNode = skillLinks[0].object + const publicIdLink = store.any(sourceNode, ns.solid('publicId'), null, doc) + expect(publicIdLink?.termType).toBe('BlankNode') + + const skillName = store.any(publicIdLink as any, ns.schema('name'), null, doc) + expect(skillName?.value).toBe('Facilitation') + + const legacyNameOnEntry = store.any(sourceNode, ns.schema('name'), null, doc) + expect(legacyNameOnEntry).toBeNull() + }) + + + it('deletes both skill entry triples and schema:skills pointer', async () => { + const store = graph() as any + const subject = sym('https://example.com/profile/card#me') + const doc = subject.doc() + const sourceNode = sym(`${doc.value}#id777`) + + store.updater = { + update: (deletions: any[], insertions: any[], callback: Function) => { + deletions.forEach((statement) => store.remove(st(statement.subject, statement.predicate, statement.object, statement.why))) + insertions.forEach((statement) => store.add(statement.subject, statement.predicate, statement.object, statement.why)) + callback('', true) + } + } + + store.add(subject, ns.schema('skills'), sourceNode, doc) + const publicIdNode = sym('skill:typescript') + store.add(sourceNode, ns.solid('publicId'), publicIdNode, doc) + store.add(publicIdNode, ns.schema('name'), literal('TypeScript'), doc) + + const plan = { + create: [], + update: [], + remove: [{ name: 'TypeScript', publicId: 'skill:typescript', sourceNode: `${doc.value}#id777`, status: 'deleted' }] + } + + await processSkillsMutations(store, subject, plan as any) + + expect(store.statementsMatching(subject, ns.schema('skills'), null, doc)).toHaveLength(0) + expect(store.statementsMatching(sourceNode, null, null, doc)).toHaveLength(0) + }) +}) diff --git a/test/profile-social-mutations.test.ts b/test/profile-social-mutations.test.ts new file mode 100644 index 00000000..fb82176e --- /dev/null +++ b/test/profile-social-mutations.test.ts @@ -0,0 +1,380 @@ +import { describe, expect, it, vi } from 'vitest' +import { Collection, graph, literal, st, sym } from 'rdflib' +import { ns } from '../src/util/ns' +import { presentSocial } from '../src/profile/sections/social/selectors' +import { processSocialMutations } from '../src/profile/sections/social/mutations' +import { findSocialAccountOption, getSocialAccountOptions, homepageForAccount } from '../src/profile/sections/social/helpers' +import { saveSocialUpdatesFailedMessageText } from '../src/profile/texts' +import { expandRdfList } from '../src/profile/sections/shared/rdfList' + +vi.mock('../src/profile/sections/social/helpers', async () => { + const actual = await vi.importActual>('../src/profile/sections/social/helpers') + return { + ...actual, + ensureSocialOntologyLoaded: vi.fn() + } +}) + +describe('Social selectors and mutations', () => { + it('provides social account options including Facebook and Instagram', () => { + const store = graph() as any + + const options = getSocialAccountOptions(store) + const labels = options.map((option) => option.label.toLowerCase()) + + expect(labels).toContain('facebook') + expect(labels).toContain('instagram') + }) + + it('matches account options from legacy account-like names', () => { + const store = graph() as any + const options = getSocialAccountOptions(store) + + expect(findSocialAccountOption(options, 'FacebookAccount')?.label).toBe('Facebook') + expect(findSocialAccountOption(options, 'InstagramAccount')?.label).toBe('Instagram') + }) + + it('selector returns empty social accounts from empty store', () => { + const store = graph() as any + const subject = sym('https://example.com/profile/card#me') + + expect(presentSocial(subject, store)).toEqual({ accounts: [] }) + }) + + it('preserves multiple unnamed accounts and their types during an empty mutation', async () => { + const store = graph() as any + const subject = sym('https://example.com/profile/card#me') + const doc = subject.doc() + const firstAccount = sym('https://example.com/profile/card#account-one') + const secondAccount = sym('https://example.com/profile/card#account-two') + const firstType = sym('https://example.com/ontology#FirstAccount') + const secondType = sym('https://example.com/ontology#SecondAccount') + const metadata = ns.schema('identifier') + + store.add(subject, ns.foaf('account'), firstAccount, doc) + store.add(subject, ns.foaf('account'), secondAccount, doc) + store.add(firstAccount, ns.rdf('type'), firstType, doc) + store.add(secondAccount, ns.rdf('type'), secondType, doc) + store.add(firstAccount, metadata, literal('one'), doc) + store.add(secondAccount, metadata, literal('two'), doc) + store.updater = { + update: (deletions: any[], insertions: any[], callback: Function) => { + deletions.forEach((statement) => store.remove(st(statement.subject, statement.predicate, statement.object, statement.why))) + insertions.forEach((statement) => store.add(statement.subject, statement.predicate, statement.object, statement.why)) + callback('', true) + } + } + + await processSocialMutations(store, subject, { create: [], update: [], remove: [] } as any) + + expect(store.holds(firstAccount, ns.rdf('type'), firstType, doc)).toBe(true) + expect(store.holds(secondAccount, ns.rdf('type'), secondType, doc)).toBe(true) + expect(store.any(firstAccount, metadata, null, doc)?.value).toBe('one') + expect(store.any(secondAccount, metadata, null, doc)?.value).toBe('two') + }) + + it('mutation surfaces unsupported store updater errors', async () => { + const store = graph() as any + const subject = sym('https://example.com/profile/card#me') + + const plan = { create: [], update: [], remove: [] } + await expect(processSocialMutations(store, subject, plan as any)).rejects.toMatchObject({ + message: saveSocialUpdatesFailedMessageText, + cause: expect.objectContaining({ + message: 'Social updates are not supported by this store updater.' + }) + }) + }) + + it('writes new social entry as id node with foaf account structure', async () => { + const store = graph() as any + const subject = sym('https://example.com/profile/card#me') + const doc = subject.doc() + + store.updater = { + update: (deletions: any[], insertions: any[], callback: Function) => { + deletions.forEach((statement) => store.remove(st(statement.subject, statement.predicate, statement.object, statement.why))) + insertions.forEach((statement) => store.add(statement.subject, statement.predicate, statement.object, statement.why)) + callback('', true) + } + } + + const plan = { + create: [{ name: 'Github', icon: '', homepage: 'https://www.github.com/sharon', sourceNode: '', status: 'new' }], + update: [], + remove: [] + } + + await processSocialMutations(store, subject, plan as any) + + const accountLinks = store.statementsMatching(subject, ns.foaf('account'), null, doc) + expect(accountLinks).toHaveLength(1) + + const expandedAccounts = accountLinks + .flatMap((statement: any) => expandRdfList(store, statement.object)) + .filter((node: any) => node.termType === 'NamedNode') + expect(expandedAccounts).toHaveLength(1) + const sourceNode = expandedAccounts[0] + expect(sourceNode.termType).toBe('NamedNode') + expect(sourceNode.value).toMatch(/^https:\/\/example\.com\/profile\/card#id\d{13}$/) + + const accountName = store.any(sourceNode, ns.foaf('accountName'), null, doc) + expect(accountName?.value).toBe('sharon') + }) + + it('normalizes duplicated profile prefix when writing accountName', async () => { + const store = graph() as any + const subject = sym('https://example.com/profile/card#me') + const doc = subject.doc() + + store.updater = { + update: (deletions: any[], insertions: any[], callback: Function) => { + deletions.forEach((statement) => store.remove(st(statement.subject, statement.predicate, statement.object, statement.why))) + insertions.forEach((statement) => store.add(statement.subject, statement.predicate, statement.object, statement.why)) + callback('', true) + } + } + + const plan = { + create: [{ + name: 'Facebook', + icon: '', + homepage: 'https://www.facebook.com/https://www.facebook.com/sharon.stratsianis', + sourceNode: '', + status: 'new' + }], + update: [], + remove: [] + } + + await processSocialMutations(store, subject, plan as any) + + const accountLinks = store.statementsMatching(subject, ns.foaf('account'), null, doc) + const expandedAccounts = accountLinks + .flatMap((statement: any) => expandRdfList(store, statement.object)) + .filter((node: any) => node.termType === 'NamedNode') + const sourceNode = expandedAccounts[0] + + const accountName = store.any(sourceNode, ns.foaf('accountName'), null, doc) + expect(accountName?.value).toBe('sharon.stratsianis') + }) + + it('normalizes GitHub www homepage to username accountName', async () => { + const store = graph() as any + const subject = sym('https://example.com/profile/card#me') + const doc = subject.doc() + + store.updater = { + update: (deletions: any[], insertions: any[], callback: Function) => { + deletions.forEach((statement) => store.remove(st(statement.subject, statement.predicate, statement.object, statement.why))) + insertions.forEach((statement) => store.add(statement.subject, statement.predicate, statement.object, statement.why)) + callback('', true) + }, + serialize: vi.fn(() => ''), + } + + store.fetcher = { + webOperation: vi.fn(async () => ({ ok: true, status: 200 })) + } + + const plan = { + create: [{ + name: 'Github', + icon: '', + homepage: 'https://www.github.com/SharonStrat', + sourceNode: '', + status: 'new' + }], + update: [], + remove: [] + } + + await processSocialMutations(store, subject, plan as any) + + const accountLinks = store.statementsMatching(subject, ns.foaf('account'), null, doc) + const expandedAccounts = accountLinks + .flatMap((statement: any) => expandRdfList(store, statement.object)) + .filter((node: any) => node.termType === 'NamedNode') + + const sourceNode = expandedAccounts[0] + const accountName = store.any(sourceNode, ns.foaf('accountName'), null, doc) + expect(accountName?.value).toBe('SharonStrat') + }) + + it('uses full accountName URL as homepage without re-prefixing', () => { + const store = graph() as any + const accountNode = sym('https://example.com/profile/card#id1') + const classNode = sym('https://solidos.github.io/profile-pane/src/ontology/socialMedia.ttl#FacebookAccount') + const doc = accountNode.doc() + + store.add(accountNode, ns.rdf('type'), classNode, doc) + store.add(classNode, ns.foaf('userProfilePrefix'), literal('https://www.facebook.com/') as any, doc) + store.add(accountNode, ns.foaf('accountName'), literal('https://www.facebook.com/sharon.stratsianis') as any, doc) + + expect(homepageForAccount(store, accountNode)).toBe('https://www.facebook.com/sharon.stratsianis') + }) + + it('persists social account order from orderedRows input', async () => { + const store = graph() as any + const subject = sym('https://example.com/profile/card#me') + const doc = subject.doc() + + const facebookNode = sym('https://example.com/profile/card#id1111111111111') + const instagramNode = sym('https://example.com/profile/card#id2222222222222') + const facebookClass = sym('https://solidos.github.io/profile-pane/src/ontology/socialMedia.ttl#FacebookAccount') + const instagramClass = sym('https://solidos.github.io/profile-pane/src/ontology/socialMedia.ttl#InstagramAccount') + + store.updater = { + update: (deletions: any[], insertions: any[], callback: Function) => { + deletions.forEach((statement) => store.remove(st(statement.subject, statement.predicate, statement.object, statement.why))) + insertions.forEach((statement) => store.add(statement.subject, statement.predicate, statement.object, statement.why)) + callback('', true) + } + } + + store.add(subject, ns.foaf('account'), new Collection([facebookNode, instagramNode]), doc) + store.add(facebookNode, ns.rdf('type'), facebookClass, doc) + store.add(facebookNode, ns.foaf('accountName'), literal('sharon-facebook'), doc) + store.add(instagramNode, ns.rdf('type'), instagramClass, doc) + store.add(instagramNode, ns.foaf('accountName'), literal('sharon-instagram'), doc) + + const orderedRows = [ + { name: 'Instagram', icon: '', homepage: 'https://www.instagram.com/sharon-instagram', sourceNode: instagramNode.value, status: 'existing' }, + { name: 'Facebook', icon: '', homepage: 'https://www.facebook.com/sharon-facebook', sourceNode: facebookNode.value, status: 'existing' } + ] as any + + await processSocialMutations(store, subject, { create: [], update: [], remove: [] } as any, orderedRows) + + const accountLists = store.statementsMatching(subject, ns.foaf('account'), null, doc) + const expanded = accountLists + .map((statement: any) => expandRdfList(store, statement.object)) + .sort((a: any[], b: any[]) => b.length - a.length)[0] + .filter((node: any) => node.termType === 'NamedNode') + + expect(expanded.map((node: any) => node.value)).toEqual([instagramNode.value, facebookNode.value]) + }) + + it('returns reordered social accounts from the selector after saving orderedRows', async () => { + const store = graph() as any + const subject = sym('https://example.com/profile/card#me') + const doc = subject.doc() + + const facebookNode = sym('https://example.com/profile/card#id1111111111111') + const instagramNode = sym('https://example.com/profile/card#id2222222222222') + const facebookClass = sym('https://solidos.github.io/profile-pane/src/ontology/socialMedia.ttl#FacebookAccount') + const instagramClass = sym('https://solidos.github.io/profile-pane/src/ontology/socialMedia.ttl#InstagramAccount') + + store.updater = { + update: (deletions: any[], insertions: any[], callback: Function) => { + deletions.forEach((statement) => store.remove(st(statement.subject, statement.predicate, statement.object, statement.why))) + insertions.forEach((statement) => store.add(statement.subject, statement.predicate, statement.object, statement.why)) + callback('', true) + } + } + + store.add(subject, ns.foaf('account'), new Collection([facebookNode, instagramNode]), doc) + store.add(facebookNode, ns.rdf('type'), facebookClass, doc) + store.add(facebookNode, ns.foaf('accountName'), literal('sharon-facebook'), doc) + store.add(instagramNode, ns.rdf('type'), instagramClass, doc) + store.add(instagramNode, ns.foaf('accountName'), literal('sharon-instagram'), doc) + + await processSocialMutations( + store, + subject, + { create: [], update: [], remove: [] } as any, + [ + { name: 'Instagram', icon: '', homepage: 'https://www.instagram.com/sharon-instagram', sourceNode: instagramNode.value, status: 'existing' }, + { name: 'Facebook', icon: '', homepage: 'https://www.facebook.com/sharon-facebook', sourceNode: facebookNode.value, status: 'existing' } + ] as any + ) + + const accounts = presentSocial(subject, store).accounts + + expect(accounts.map((account) => account.sourceNode.value)).toEqual([instagramNode.value, facebookNode.value]) + expect(accounts.map((account) => account.homepage)).toEqual([ + 'https://www.instagram.com/sharon-instagram', + 'https://www.facebook.com/sharon-facebook' + ]) + }) + + it('preserves unrelated statements on reused social entry nodes', async () => { + const store = graph() as any + const subject = sym('https://example.com/profile/card#me') + const doc = subject.doc() + + const facebookNode = sym('https://example.com/profile/card#id1111111111111') + const instagramNode = sym('https://example.com/profile/card#id2222222222222') + const facebookClass = sym('https://solidos.github.io/profile-pane/src/ontology/socialMedia.ttl#FacebookAccount') + const instagramClass = sym('https://solidos.github.io/profile-pane/src/ontology/socialMedia.ttl#InstagramAccount') + + store.updater = { + update: (deletions: any[], insertions: any[], callback: Function) => { + deletions.forEach((statement) => store.remove(st(statement.subject, statement.predicate, statement.object, statement.why))) + insertions.forEach((statement) => store.add(statement.subject, statement.predicate, statement.object, statement.why)) + callback('', true) + } + } + + store.add(subject, ns.foaf('account'), new Collection([facebookNode, instagramNode]), doc) + store.add(facebookNode, ns.rdf('type'), facebookClass, doc) + store.add(facebookNode, ns.foaf('accountName'), literal('sharon-facebook'), doc) + store.add(instagramNode, ns.rdf('type'), instagramClass, doc) + store.add(instagramNode, ns.foaf('accountName'), literal('sharon-instagram'), doc) + store.add(instagramNode, ns.rdfs('comment'), literal('keep-me'), doc) + + await processSocialMutations( + store, + subject, + { create: [], update: [], remove: [] } as any, + [ + { name: 'Instagram', icon: '', homepage: 'https://www.instagram.com/sharon-instagram', sourceNode: instagramNode.value, status: 'existing' }, + { name: 'Facebook', icon: '', homepage: 'https://www.facebook.com/sharon-facebook', sourceNode: facebookNode.value, status: 'existing' } + ] as any + ) + + expect(store.any(instagramNode, ns.rdfs('comment'), null, doc)?.value).toBe('keep-me') + expect(presentSocial(subject, store).accounts.map((account) => account.sourceNode.value)).toEqual([ + instagramNode.value, + facebookNode.value + ]) + }) + + it('writes other accounts with homepage and icon instead of accountName', async () => { + const store = graph() as any + const subject = sym('https://example.com/profile/card#me') + const doc = subject.doc() + + store.updater = { + update: (deletions: any[], insertions: any[], callback: Function) => { + deletions.forEach((statement) => store.remove(st(statement.subject, statement.predicate, statement.object, statement.why))) + insertions.forEach((statement) => store.add(statement.subject, statement.predicate, statement.object, statement.why)) + callback('', true) + } + } + + await processSocialMutations(store, subject, { + create: [{ + name: 'Other', + icon: 'https://example.com/icon.svg', + homepage: 'https://example.com/custom-profile', + sourceNode: '', + status: 'new' + }], + update: [], + remove: [] + } as any) + + const accountLinks = store.statementsMatching(subject, ns.foaf('account'), null, doc) + const expandedAccounts = accountLinks + .flatMap((statement: any) => expandRdfList(store, statement.object)) + .filter((node: any) => node.termType === 'NamedNode') + const sourceNode = expandedAccounts[0] + + expect(store.any(sourceNode, ns.foaf('homepage'), null, doc)?.value).toBe('https://example.com/custom-profile') + expect(store.any(sourceNode, ns.foaf('icon'), null, doc)?.value).toBe('https://example.com/icon.svg') + expect(store.any(sourceNode, ns.foaf('accountName'), null, doc)).toBeNull() + }) + + + +}) diff --git a/test/profile-social-presenter.test.ts b/test/profile-social-presenter.test.ts new file mode 100644 index 00000000..70b49a1e --- /dev/null +++ b/test/profile-social-presenter.test.ts @@ -0,0 +1,91 @@ +import { beforeAll, describe, expect, it } from 'vitest' +import { presentSocial } from '../src/profile/sections/social/selectors' +import { sym, parse } from 'rdflib' +import { store } from '../src/index' + +const subject = sym('https://janedoe.example/profile/card#me') +const doc = subject.doc() + +describe('SocialPresenter', () => { + beforeAll(async () => { + store.removeDocument(doc) + const turtle = ` + @prefix : <#>. + @prefix foaf: . + @prefix vcard: . + @prefix solid: . + @prefix foaf: . + @prefix soc: . + @prefix rdfs: . + + :me foaf:name "Jane Doe"; + foaf:img ; + vcard:role "Test Double"; + vcard:organization-name "Solid Community"; + solid:preferredObjectPronoun "they"; + solid:preferredRelativePronoun "them"; + solid:preferredSubjectPronoun "their"; + vcard:hasAddress [ + vcard:locality "Hamburg"; + vcard:country-name "Germany"; + ]; + foaf:account + ( :id1729675527480 :id1729912807298 :id1729912986863 + :id1730055928507 :id1730056067040 :id1730056125033 :id1730056175230 + :id1730056248989 :id1730058497607 :id1730059685809 :id1731874088097 + :id1732291980762 :id1732292364795 :id1732292968661 ), + ( :id1729675527480 :id1729912807298 :id1729912986863 + :id1730055928507 :id1730056067040 :id1730056125033 :id1730056175230 + :id1730056248989 :id1730058497607 :id1730059685809 ); + . + :id1729675527480 a soc:BlueSkyAccount; foaf:accountName "timbl.bsky.social". + + :id1729912807298 a soc:FacebookAccount; foaf:accountName "tim.bernerslee.9". + + :id1729912986863 a soc:InstagramAccount; foaf:accountName "timblee". + + :id1730055928507 a soc:MastodonAccount; foaf:accountName "@timbl@w3c.social". + + :id1730056067040 a soc:RedditAccount. + + :id1730056125033 a soc:RedditAccount; foaf:accountName "timbl". + + :id1730056175230 a soc:TwitterAccount; foaf:accountName "@timberners_lee". + + :id1730056248989 + a soc:OtherAccount; + rdfs:label "My homepage at W3C"; + foaf:homepage ; + foaf:icon . + :id1730058497607 a soc:SnapchatAccount. + + :id1730059685809 a soc:TiktokAccount. + + :id1730387315524 a soc:GithubAccount; foaf:accountName "timbl". + + :id1730387353050 + a soc:MatrixAccount; + foaf:accountName "@timbl-54d26c98db8155e6700f7312:gitter.im". + :id1731874088097 + a soc:OtherAccount; rdfs:label "Strava"; foaf:homepage . + :id1732291980762 a soc:OrcidAccount; foaf:accountName "0000-0003-1279-3709". + + :id1732292364795 a soc:StravaAccount; foaf:accountName "12657979". + + :id1732292968661 a soc:TumblrAccount; foaf:accountName "w3cmemes". + ` + parse(turtle, store, doc.uri) + }) + + it('returns an empty account list when no social accounts exist', () => { + const jane = sym('https://example.com/no-social#me') + const result = presentSocial(jane, store) + expect(result).toEqual({ accounts: [] }) + }) + + it('deduplicates accounts in presentSocial', () => { + // Call presentSocial + const result = presentSocial(subject, store) + expect(result.accounts.length).toBe(10) + }) +}) diff --git a/test/profileProjectSelectors.test.ts b/test/profileProjectSelectors.test.ts new file mode 100644 index 00000000..b75fe258 --- /dev/null +++ b/test/profileProjectSelectors.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, it, vi } from 'vitest' +import { blankNode, literal, st, Store, sym } from 'rdflib' +import type { LiveStore, NamedNode } from 'rdflib' +import { ns } from '../src/util/ns' +import { ensureProjectDocumentsLoaded, presentProjects } from '../src/profile/sections/projects/selectors' + +const subject = sym('https://example.test/profile/card#me') +const profile = subject.doc() + +describe('project selectors', () => { + it('reads direct, literal, blank-node, and RDF-list community links', async () => { + const store = new Store() + const direct = sym('https://example.test/projects/direct#it') + const literalProject = sym('https://example.test/projects/literal#it') + const blankProject = sym('https://example.test/projects/blank#it') + const listProject = sym('https://example.test/projects/list#it') + const blankLink = blankNode('project-link') + const listHead = blankNode('projects') + const listTail = blankNode('projects-tail') + + store.add(st(subject, ns.solid('community'), direct, profile)) + store.add(st(subject, ns.solid('community'), literal(literalProject.value), profile)) + store.add(st(subject, ns.solid('community'), blankLink, profile)) + store.add(st(blankLink, ns.schema('url'), blankProject, profile)) + store.add(st(subject, ns.solid('community'), listHead, profile)) + store.add(st(listHead, ns.rdf('first'), listProject, profile)) + store.add(st(listHead, ns.rdf('rest'), listTail, profile)) + store.add(st(listTail, ns.rdf('first'), direct, profile)) + store.add(st(listTail, ns.rdf('rest'), ns.rdf('nil'), profile)) + + store.add(st(direct, ns.foaf('name'), literal('Direct project'), direct.doc())) + store.add(st(direct, ns.vcard('organization-name'), literal('Direct org'), direct.doc())) + store.add(st(direct, ns.rdf('type'), ns.schema('Project'), direct.doc())) + store.add(st(direct, ns.foaf('img'), sym('https://example.test/direct.png'), direct.doc())) + store.add(st(literalProject, ns.foaf('name'), literal('Literal project'), literalProject.doc())) + store.add(st(literalProject, ns.rdf('type'), ns.schema('Community'), literalProject.doc())) + store.add(st(blankProject, ns.foaf('name'), literal('Blank project'), blankProject.doc())) + store.add(st(listProject, ns.foaf('name'), literal('List project'), listProject.doc())) + + await expect(presentProjects(subject, store as unknown as LiveStore)).resolves.toEqual([ + expect.objectContaining({ + url: direct.value, + title: 'Direct project', + orgName: 'Direct org', + imageUrl: 'https://example.test/direct.png', + category: 'project', + sourceNode: direct + }), + expect.objectContaining({ + url: literalProject.value, + title: 'Literal project', + category: 'community' + }), + expect.objectContaining({ url: blankProject.value, title: 'Blank project', category: 'unknown' }), + expect.objectContaining({ url: listProject.value, title: 'List project', category: 'unknown' }) + ]) + }) + + it('deduplicates normalized URLs and continues loading after an individual error', async () => { + const store = new Store() + const available = sym('https://example.test/projects/available#one') + const unavailable = sym('https://example.test/projects/unavailable#one') + const load = vi.fn(async (doc: NamedNode) => { + if (doc.value === unavailable.doc().value) throw new Error('Not found') + }) + const requested: Record = { [unavailable.doc().value]: 404 } + ;(store as any).fetcher = { load, requested } + + store.add(st(subject, ns.solid('community'), available, profile)) + store.add(st(subject, ns.solid('community'), literal(available.value), profile)) + store.add(st(subject, ns.solid('community'), unavailable, profile)) + + await expect(ensureProjectDocumentsLoaded(subject, store as unknown as LiveStore)).resolves.toBeUndefined() + expect(load).toHaveBeenCalledTimes(2) + expect(requested).not.toHaveProperty(unavailable.doc().value) + }) +}) diff --git a/test/profileReadLogic.test.ts b/test/profileReadLogic.test.ts new file mode 100644 index 00000000..5a9a1027 --- /dev/null +++ b/test/profileReadLogic.test.ts @@ -0,0 +1,55 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { blankNode, literal, st, sym } from 'rdflib' +import { solidLogicSingleton, store } from '../src' +import { ns } from '../src/util/ns' + +const subject = sym('https://example.com/profile/card#me') +const document = subject.doc() + +afterEach(() => { + store.removeDocument(document) +}) + +describe('profile read API', () => { + it('reads profile sections from the singleton store', () => { + const email = blankNode('email') + const phone = blankNode('phone') + const address = blankNode('address') + const education = blankNode('education') + const skill = blankNode('skill') + const customSkill = blankNode('custom-skill') + + store.add(st(subject, ns.vcard('note'), literal('Building accessible Solid apps.'), document)) + store.add(st(subject, ns.vcard('hasEmail'), email, document)) + store.add(st(email, ns.vcard('value'), sym('mailto:jane@example.com'), document)) + store.add(st(subject, ns.vcard('hasTelephone'), phone, document)) + store.add(st(phone, ns.vcard('value'), sym('tel:+15551234567'), document)) + store.add(st(subject, ns.vcard('hasAddress'), address, document)) + store.add(st(address, ns.vcard('locality'), literal('Boston'), document)) + store.add(st(education, ns.org('member'), subject, document)) + store.add(st(education, ns.rdf('type'), ns.schema('EducationalOccupationalCredential'), document)) + store.add(st(education, ns.schema('name'), literal('State University'), document)) + store.add(st(education, ns.schema('startDate'), literal('2020-09-01'), document)) + store.add(st(subject, ns.schema('skills'), skill, document)) + store.add(st(skill, ns.solid('publicId'), customSkill, document)) + store.add(st(customSkill, ns.schema('name'), literal('Facilitation'), document)) + store.add(st(subject, ns.schema('knowsLanguage'), sym('https://www.w3.org/ns/iana/language-code/en'), document)) + + expect(solidLogicSingleton.profile.getBio(subject)).toEqual({ + description: 'Building accessible Solid apps.', + sourceNode: subject + }) + expect(solidLogicSingleton.profile.getContactInfo(subject).emails[0].valueNode.value).toBe('mailto:jane@example.com') + expect(solidLogicSingleton.profile.getContactInfo(subject).phones[0].valueNode.value).toBe('tel:+15551234567') + expect(solidLogicSingleton.profile.getContactInfo(subject).addresses[0].locality).toBe('Boston') + expect(solidLogicSingleton.profile.getEducation(subject)).toEqual([ + expect.objectContaining({ school: 'State University', sourceNode: education }) + ]) + expect(solidLogicSingleton.profile.getSkillDetails(subject)).toEqual([ + expect.objectContaining({ name: 'Facilitation', publicId: '_:custom-skill', sourceNode: skill }) + ]) + expect(solidLogicSingleton.profile.getLanguages(subject)).toEqual([ + expect.objectContaining({ name: 'English' }) + ]) + }) +}) diff --git a/test/profileResumeSelectors.test.ts b/test/profileResumeSelectors.test.ts new file mode 100644 index 00000000..09f0c3b2 --- /dev/null +++ b/test/profileResumeSelectors.test.ts @@ -0,0 +1,101 @@ +import { describe, expect, it } from 'vitest' +import { blankNode, graph, literal, sym } from 'rdflib' +import { presentCV } from '../src/profile/sections/resume/selectors' +import { ns } from '../src/util/ns' + +const subject = sym('https://example.com/profile/card#me') +const document = subject.doc() + +function addMembership (store: any, membership: any): void { + store.add(membership, ns.org('member'), subject, document) +} + +describe('resume selectors', () => { + it('extracts role names and types from membership role resources', () => { + const store = graph() + const membership = sym(`${document.value}#role`) + const role = sym('https://example.com/roles/engineer') + addMembership(store, membership) + store.add(membership, ns.rdf('type'), ns.solid('PastRole'), document) + store.add(membership, ns.org('role'), role, document) + store.add(role, ns.schema('name'), literal('Engineer'), document) + store.add(membership, ns.vcard('role'), literal('Platform'), document) + + expect(presentCV(subject, store as any)).toEqual([ + expect.objectContaining({ title: 'Engineer - Platform', roleType: 'PastRole', sourceNode: membership }) + ]) + }) + + it('extracts organization metadata, prioritizing public-id names and classification', () => { + const store = graph() + const membership = sym(`${document.value}#role`) + const organization = sym(`${document.value}#organization`) + const publicId = sym('https://catalog.example/organizations/acme') + addMembership(store, membership) + store.add(membership, ns.org('organization'), organization, document) + store.add(organization, ns.schema('name'), literal('Acme Incorporated'), document) + store.add(organization, ns.solid('publicId'), publicId, document) + store.add(publicId, ns.schema('name'), literal('Acme'), document) + store.add(organization, ns.org('classification'), literal('Cooperative'), document) + store.add(organization, ns.org('location'), literal('Remote'), document) + store.add(organization, ns.schema('uri'), sym('https://acme.example/'), document) + + expect(presentCV(subject, store as any)[0]).toMatchObject({ + orgName: 'Acme', + orgPublicId: publicId.value, + orgType: 'Cooperative', + orgLocation: 'Remote', + orgHomePage: 'https://acme.example/' + }) + }) + + it('puts current roles first and otherwise sorts roles reverse chronologically', () => { + const store = graph() + const current = sym(`${document.value}#current`) + const recent = sym(`${document.value}#recent`) + const older = sym(`${document.value}#older`) + for (const [membership, title, startDate, endDate] of [ + [older, 'Older', '2018-01-01', '2019-01-01'], + [current, 'Current', '2020-01-01', undefined], + [recent, 'Recent', '2021-01-01', '2022-01-01'] + ] as const) { + addMembership(store, membership) + store.add(membership, ns.vcard('role'), literal(title), document) + store.add(membership, ns.schema('startDate'), literal(startDate), document) + if (endDate) store.add(membership, ns.schema('endDate'), literal(endDate), document) + } + + const roles = presentCV(subject, store as any) + expect(roles.map(role => role.title)).toEqual(['Current', 'Recent', 'Older']) + expect(roles[0].isCurrentRole).toBe(true) + }) + + it('deduplicates matching roles and retains the named membership over a blank node', () => { + const store = graph() + const blankMembership = blankNode('role') + const namedMembership = sym(`${document.value}#role`) + for (const membership of [blankMembership, namedMembership]) { + addMembership(store, membership) + store.add(membership, ns.vcard('role'), literal('Engineer'), document) + store.add(membership, ns.schema('startDate'), literal('2020-01-01'), document) + store.add(membership, ns.schema('endDate'), literal('2021-01-01'), document) + } + + expect(presentCV(subject, store as any)).toEqual([ + expect.objectContaining({ sourceNode: namedMembership }) + ]) + }) + + it('excludes education memberships from roles', () => { + const store = graph() + const education = sym(`${document.value}#education`) + const role = sym(`${document.value}#role`) + addMembership(store, education) + store.add(education, ns.rdf('type'), ns.schema('EducationalOccupationalCredential'), document) + store.add(education, ns.vcard('role'), literal('Student'), document) + addMembership(store, role) + store.add(role, ns.vcard('role'), literal('Developer'), document) + + expect(presentCV(subject, store as any).map(item => item.title)).toEqual(['Developer']) + }) +}) diff --git a/test/profileSocialRead.test.ts b/test/profileSocialRead.test.ts new file mode 100644 index 00000000..1fac30a8 --- /dev/null +++ b/test/profileSocialRead.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, it } from 'vitest' +import { blankNode, graph, literal, sym } from 'rdflib' +import { socialAccountOntologyFor } from '../src/profile/ontology/social/accounts' +import { + getSocialAccounts, + socialAccountHomepage, + socialAccountIcon, + socialAccountName +} from '../src/profile/sections/social/read' +import { ns } from '../src/util/ns' + +const subject = sym('https://example.test/profile/card#me') +const document = subject.doc() +const github = socialAccountOntologyFor('https://solidos.github.io/profile-pane/src/ontology/socialMedia.ttl#GithubAccount')! + +describe('social profile reads', () => { + it('expands account RDF lists in order', () => { + const store = graph() + const first = sym('https://example.test/accounts/first') + const second = sym('https://example.test/accounts/second') + const list = blankNode('accounts') + const tail = blankNode('accounts-tail') + + store.add(subject, ns.foaf('account'), list, document) + store.add(list, ns.rdf('first'), first, document) + store.add(list, ns.rdf('rest'), tail, document) + store.add(tail, ns.rdf('first'), second, document) + store.add(tail, ns.rdf('rest'), ns.rdf('nil'), document) + store.add(first, ns.foaf('accountName'), literal('first'), document) + store.add(second, ns.foaf('accountName'), literal('second'), document) + + expect(getSocialAccounts(subject, store).accounts.map(account => account.sourceNode.value)).toEqual([ + first.value, + second.value + ]) + }) + + it('uses ontology metadata to resolve an account label, icon, and homepage', () => { + const store = graph() + const account = sym('https://example.test/accounts/github') + + store.add(account, ns.rdf('type'), sym(github.classUri), document) + store.add(account, ns.foaf('accountName'), literal('octocat'), document) + + expect(socialAccountName(store, account)).toBe('Github') + expect(socialAccountIcon(store, account)).toBe(github.icon) + expect(socialAccountHomepage(store, account)).toBe('https://www.github.com/octocat') + }) + + it('deduplicates named accounts while retaining accounts without a name', () => { + const store = graph() + const duplicateOne = sym('https://example.test/accounts/duplicate-one') + const duplicateTwo = sym('https://example.test/accounts/duplicate-two') + const unnamedOne = sym('https://example.test/accounts/unnamed-one') + const unnamedTwo = sym('https://example.test/accounts/unnamed-two') + + for (const account of [duplicateOne, duplicateTwo, unnamedOne, unnamedTwo]) { + store.add(subject, ns.foaf('account'), account, document) + } + store.add(duplicateOne, ns.foaf('accountName'), literal('same-account'), document) + store.add(duplicateTwo, ns.foaf('accountName'), literal('same-account'), document) + + expect(getSocialAccounts(subject, store).accounts.map(account => account.sourceNode.value)).toEqual([ + duplicateOne.value, + unnamedOne.value, + unnamedTwo.value + ]) + }) + + it('returns stable empty and fallback values for missing account data', () => { + const store = graph() + const account = sym('https://example.test/accounts/missing') + + store.add(subject, ns.foaf('account'), account, document) + + expect(getSocialAccounts(subject, store)).toEqual({ + accounts: [{ + name: 'Unknown Account', + icon: '', + homepage: '', + sourceNode: account + }] + }) + expect(getSocialAccounts(sym('https://example.test/without-accounts#me'), store)).toEqual({ accounts: [] }) + }) +})