diff --git a/package-lock.json b/package-lock.json index 99e23da..93591a3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -33909,9 +33909,13 @@ } }, "node_modules/uuid": { - "version": "9.0.0", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.0.tgz", - "integrity": "sha512-MXcSTerfPa4uqyzStbRoTgt5XIe3x5+42+q1sDuy3R5MDk66URdLMOZe5aPX/SQd+kuYAh0FdP/pO28IkQyTeg==", + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", + "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], "bin": { "uuid": "dist/bin/uuid" } @@ -35611,7 +35615,8 @@ "react-dom": "^18.2.0", "react-router-dom": "^6.15.0", "react-scripts": "5.0.1", - "solid-authn-react-native": "^2.0.3" + "solid-authn-react-native": "^2.0.3", + "uuid": "^9.0.1" }, "devDependencies": { "@craco/craco": "^7.1.0", @@ -46610,7 +46615,8 @@ "react-router-dom": "^6.15.0", "react-scripts": "5.0.1", "solid-authn-react-native": "^2.0.3", - "tsconfig-paths-webpack-plugin": "^4.1.0" + "tsconfig-paths-webpack-plugin": "^4.1.0", + "uuid": "*" }, "dependencies": { "@craco/craco": { @@ -69280,9 +69286,9 @@ "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==" }, "uuid": { - "version": "9.0.0", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.0.tgz", - "integrity": "sha512-MXcSTerfPa4uqyzStbRoTgt5XIe3x5+42+q1sDuy3R5MDk66URdLMOZe5aPX/SQd+kuYAh0FdP/pO28IkQyTeg==" + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", + "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==" }, "v8-compile-cache": { "version": "2.3.0", diff --git a/packages/demo-react/package.json b/packages/demo-react/package.json index 8bedb12..dcfc696 100644 --- a/packages/demo-react/package.json +++ b/packages/demo-react/package.json @@ -8,7 +8,8 @@ "react-dom": "^18.2.0", "react-router-dom": "^6.15.0", "react-scripts": "5.0.1", - "solid-authn-react-native": "^2.0.3" + "solid-authn-react-native": "^2.0.3", + "uuid": "^9.0.1" }, "scripts": { "start": "craco start", diff --git a/packages/demo-react/src/.ldo/post.context.ts b/packages/demo-react/src/.ldo/post.context.ts new file mode 100644 index 0000000..dafbe33 --- /dev/null +++ b/packages/demo-react/src/.ldo/post.context.ts @@ -0,0 +1,31 @@ +import { ContextDefinition } from "jsonld"; + +/** + * ============================================================================= + * postContext: JSONLD Context for post + * ============================================================================= + */ +export const postContext: ContextDefinition = { + type: { + "@id": "@type", + }, + SocialMediaPosting: "http://schema.org/SocialMediaPosting", + CreativeWork: "http://schema.org/CreativeWork", + Thing: "http://schema.org/Thing", + articleBody: { + "@id": "http://schema.org/articleBody", + "@type": "http://www.w3.org/2001/XMLSchema#string", + }, + uploadDate: { + "@id": "http://schema.org/uploadDate", + "@type": "http://www.w3.org/2001/XMLSchema#date", + }, + image: { + "@id": "http://schema.org/image", + "@type": "@id", + }, + publisher: { + "@id": "http://schema.org/publisher", + "@type": "@id", + }, +}; diff --git a/packages/demo-react/src/.ldo/post.schema.ts b/packages/demo-react/src/.ldo/post.schema.ts new file mode 100644 index 0000000..39e8b63 --- /dev/null +++ b/packages/demo-react/src/.ldo/post.schema.ts @@ -0,0 +1,155 @@ +import { Schema } from "shexj"; + +/** + * ============================================================================= + * postSchema: ShexJ Schema for post + * ============================================================================= + */ +export const postSchema: Schema = { + type: "Schema", + shapes: [ + { + id: "https://example.com/PostSh", + type: "ShapeDecl", + shapeExpr: { + type: "Shape", + expression: { + type: "EachOf", + expressions: [ + { + type: "TripleConstraint", + predicate: "http://www.w3.org/1999/02/22-rdf-syntax-ns#type", + valueExpr: { + type: "NodeConstraint", + values: [ + "http://schema.org/SocialMediaPosting", + "http://schema.org/CreativeWork", + "http://schema.org/Thing", + ], + }, + }, + { + type: "TripleConstraint", + predicate: "http://schema.org/articleBody", + valueExpr: { + type: "NodeConstraint", + datatype: "http://www.w3.org/2001/XMLSchema#string", + }, + min: 0, + max: 1, + annotations: [ + { + type: "Annotation", + predicate: "http://www.w3.org/2000/01/rdf-schema#label", + object: { + value: "articleBody", + }, + }, + { + type: "Annotation", + predicate: "http://www.w3.org/2000/01/rdf-schema#comment", + object: { + value: "The actual body of the article. ", + }, + }, + ], + }, + { + type: "TripleConstraint", + predicate: "http://schema.org/uploadDate", + valueExpr: { + type: "NodeConstraint", + datatype: "http://www.w3.org/2001/XMLSchema#date", + }, + annotations: [ + { + type: "Annotation", + predicate: "http://www.w3.org/2000/01/rdf-schema#label", + object: { + value: "uploadDate", + }, + }, + { + type: "Annotation", + predicate: "http://www.w3.org/2000/01/rdf-schema#comment", + object: { + value: + "Date when this media object was uploaded to this site.", + }, + }, + ], + }, + { + type: "TripleConstraint", + predicate: "http://schema.org/image", + valueExpr: { + type: "NodeConstraint", + nodeKind: "iri", + }, + min: 0, + max: 1, + annotations: [ + { + type: "Annotation", + predicate: "http://www.w3.org/2000/01/rdf-schema#label", + object: { + value: "image", + }, + }, + { + type: "Annotation", + predicate: "http://www.w3.org/2000/01/rdf-schema#comment", + object: { + value: + "A media object that encodes this CreativeWork. This property is a synonym for encoding.", + }, + }, + ], + }, + { + type: "TripleConstraint", + predicate: "http://schema.org/publisher", + valueExpr: { + type: "NodeConstraint", + nodeKind: "iri", + }, + annotations: [ + { + type: "Annotation", + predicate: "http://www.w3.org/2000/01/rdf-schema#label", + object: { + value: "publisher", + }, + }, + { + type: "Annotation", + predicate: "http://www.w3.org/2000/01/rdf-schema#comment", + object: { + value: "The publisher of the creative work.", + }, + }, + ], + }, + ], + }, + annotations: [ + { + type: "Annotation", + predicate: "http://www.w3.org/2000/01/rdf-schema#label", + object: { + value: "SocialMediaPost", + }, + }, + { + type: "Annotation", + predicate: "http://www.w3.org/2000/01/rdf-schema#comment", + object: { + value: + "A post to a social media platform, including blog posts, tweets, Facebook posts, etc.", + }, + }, + ], + }, + }, + ], +}; diff --git a/packages/demo-react/src/.ldo/post.shapeTypes.ts b/packages/demo-react/src/.ldo/post.shapeTypes.ts new file mode 100644 index 0000000..4c50683 --- /dev/null +++ b/packages/demo-react/src/.ldo/post.shapeTypes.ts @@ -0,0 +1,19 @@ +import { ShapeType } from "@ldo/ldo"; +import { postSchema } from "./post.schema"; +import { postContext } from "./post.context"; +import { PostSh } from "./post.typings"; + +/** + * ============================================================================= + * LDO ShapeTypes post + * ============================================================================= + */ + +/** + * PostSh ShapeType + */ +export const PostShShapeType: ShapeType = { + schema: postSchema, + shape: "https://example.com/PostSh", + context: postContext, +}; diff --git a/packages/demo-react/src/.ldo/post.typings.ts b/packages/demo-react/src/.ldo/post.typings.ts new file mode 100644 index 0000000..9ebaf71 --- /dev/null +++ b/packages/demo-react/src/.ldo/post.typings.ts @@ -0,0 +1,45 @@ +import { ContextDefinition } from "jsonld"; + +/** + * ============================================================================= + * Typescript Typings for post + * ============================================================================= + */ + +/** + * PostSh Type + */ +export interface PostSh { + "@id"?: string; + "@context"?: ContextDefinition; + type: + | { + "@id": "SocialMediaPosting"; + } + | { + "@id": "CreativeWork"; + } + | { + "@id": "Thing"; + }; + /** + * The actual body of the article. + */ + articleBody?: string; + /** + * Date when this media object was uploaded to this site. + */ + uploadDate: string; + /** + * A media object that encodes this CreativeWork. This property is a synonym for encoding. + */ + image?: { + "@id": string; + }; + /** + * The publisher of the creative work. + */ + publisher: { + "@id": string; + }; +} diff --git a/packages/demo-react/src/.ldo/solidProfile.context.ts b/packages/demo-react/src/.ldo/solidProfile.context.ts index 1d7c0a1..82a8edf 100644 --- a/packages/demo-react/src/.ldo/solidProfile.context.ts +++ b/packages/demo-react/src/.ldo/solidProfile.context.ts @@ -1,4 +1,4 @@ -import type { ContextDefinition } from "jsonld"; +import { ContextDefinition } from "jsonld"; /** * ============================================================================= @@ -8,7 +8,6 @@ import type { ContextDefinition } from "jsonld"; export const solidProfileContext: ContextDefinition = { type: { "@id": "@type", - "@container": "@set", }, Person: "http://schema.org/Person", Person2: "http://xmlns.com/foaf/0.1/Person", @@ -64,7 +63,6 @@ export const solidProfileContext: ContextDefinition = { value: { "@id": "http://www.w3.org/2006/vcard/ns#value", "@type": "@id", - "@container": "@set", }, hasPhoto: { "@id": "http://www.w3.org/2006/vcard/ns#hasPhoto", diff --git a/packages/demo-react/src/.ldo/solidProfile.schema.ts b/packages/demo-react/src/.ldo/solidProfile.schema.ts index 41c0ce3..69466fc 100644 --- a/packages/demo-react/src/.ldo/solidProfile.schema.ts +++ b/packages/demo-react/src/.ldo/solidProfile.schema.ts @@ -1,4 +1,4 @@ -import type { Schema } from "shexj"; +import { Schema } from "shexj"; /** * ============================================================================= diff --git a/packages/demo-react/src/.ldo/solidProfile.shapeTypes.ts b/packages/demo-react/src/.ldo/solidProfile.shapeTypes.ts index 02ebc1a..71426e4 100644 --- a/packages/demo-react/src/.ldo/solidProfile.shapeTypes.ts +++ b/packages/demo-react/src/.ldo/solidProfile.shapeTypes.ts @@ -1,7 +1,7 @@ -import type { ShapeType } from "@ldo/ldo"; +import { ShapeType } from "@ldo/ldo"; import { solidProfileSchema } from "./solidProfile.schema"; import { solidProfileContext } from "./solidProfile.context"; -import type { +import { SolidProfileShape, AddressShape, EmailShape, diff --git a/packages/demo-react/src/.ldo/solidProfile.typings.ts b/packages/demo-react/src/.ldo/solidProfile.typings.ts index eb884c4..535111b 100644 --- a/packages/demo-react/src/.ldo/solidProfile.typings.ts +++ b/packages/demo-react/src/.ldo/solidProfile.typings.ts @@ -1,4 +1,4 @@ -import type { ContextDefinition } from "jsonld"; +import { ContextDefinition } from "jsonld"; /** * ============================================================================= diff --git a/packages/demo-react/src/.shapes/post.shex b/packages/demo-react/src/.shapes/post.shex new file mode 100644 index 0000000..ae75425 --- /dev/null +++ b/packages/demo-react/src/.shapes/post.shex @@ -0,0 +1,23 @@ +PREFIX rdf: +PREFIX rdfs: +PREFIX xsd: +PREFIX ex: +BASE + +ex:PostSh { + a [ ] ; + xsd:string? + // rdfs:label '''articleBody''' + // rdfs:comment '''The actual body of the article. ''' ; + xsd:date + // rdfs:label '''uploadDate''' + // rdfs:comment '''Date when this media object was uploaded to this site.''' ; + IRI ? + // rdfs:label '''image''' + // rdfs:comment '''A media object that encodes this CreativeWork. This property is a synonym for encoding.''' ; + IRI + // rdfs:label '''publisher''' + // rdfs:comment '''The publisher of the creative work.''' ; +} +// rdfs:label '''SocialMediaPost''' +// rdfs:comment '''A post to a social media platform, including blog posts, tweets, Facebook posts, etc.''' diff --git a/packages/demo-react/src/Header.tsx b/packages/demo-react/src/Header.tsx index 4070f24..668a4fb 100644 --- a/packages/demo-react/src/Header.tsx +++ b/packages/demo-react/src/Header.tsx @@ -1,7 +1,8 @@ import { useState } from "react"; import type { FunctionComponent } from "react"; import React from "react"; -import { useResource, useSolidAuth } from "@ldo/solid-react"; +import { useResource, useSolidAuth, useSubject } from "@ldo/solid-react"; +import { SolidProfileShapeShapeType } from "./.ldo/solidProfile.shapeTypes"; const DEFAULT_ISSUER = "https://solidweb.me"; @@ -9,12 +10,13 @@ export const LoggedInHeader: FunctionComponent<{ webId: string }> = ({ webId, }) => { const webIdResource = useResource(webId); + const profile = useSubject(SolidProfileShapeShapeType, webId); const { logout } = useSolidAuth(); return ( <> Logged in as {webId}. Welcome{" "} - {webIdResource.isReading() ? "LOADING NAME" : "Cool Dude"} + {webIdResource.isReading() ? "LOADING NAME" : profile.fn} diff --git a/packages/demo-react/src/dashboard/Dashboard.tsx b/packages/demo-react/src/dashboard/Dashboard.tsx index 4eb5c37..4f06bd1 100644 --- a/packages/demo-react/src/dashboard/Dashboard.tsx +++ b/packages/demo-react/src/dashboard/Dashboard.tsx @@ -16,7 +16,7 @@ export const Dashboard: FunctionComponent = ({ return (
- +

{mainContainer.children().map((child) => ( diff --git a/packages/demo-react/src/dashboard/UploadButton.tsx b/packages/demo-react/src/dashboard/UploadButton.tsx index 62f38ff..298618f 100644 --- a/packages/demo-react/src/dashboard/UploadButton.tsx +++ b/packages/demo-react/src/dashboard/UploadButton.tsx @@ -1,10 +1,91 @@ -import React, { useCallback } from "react"; -import type { FunctionComponent } from "react"; +import React, { useCallback, useState, useRef } from "react"; +import type { FunctionComponent, FormEvent } from "react"; +import type { Container, Leaf } from "@ldo/solid"; +import { v4 } from "uuid"; +import { useLdo, useSolidAuth } from "@ldo/solid-react"; +import { PostShShapeType } from "../.ldo/post.shapeTypes"; -export const UploadButton: FunctionComponent = () => { - const upload = useCallback(() => { - const _message = prompt("Type a message for your post"); - }, []); +export const UploadButton: FunctionComponent<{ mainContainer: Container }> = ({ + mainContainer, +}) => { + const [message, setMessage] = useState(""); + const [selectedFile, setSelectedFile] = useState(); + const fileInputRef = useRef(null); + const { createData, commitData } = useLdo(); + const { session } = useSolidAuth(); + const onSubmit = useCallback( + async (e: FormEvent) => { + e.preventDefault(); - return ; + // Create the container file + const mediaContainer = await mainContainer.createChildAndOverwrite( + `${v4()}/`, + ); + if (mediaContainer.type === "error") { + alert(mediaContainer.message); + return; + } + + // Upload Image + let uploadedImage: Leaf | undefined; + if (selectedFile) { + const result = await mediaContainer.uploadChildAndOverwrite( + selectedFile.name, + selectedFile, + selectedFile.type, + ); + if (result.type === "error") { + alert(result.message); + await mediaContainer.delete(); + return; + } + uploadedImage = result; + } + + // Create Post + const indexResource = mediaContainer.child("index.ttl"); + const post = createData( + PostShShapeType, + indexResource.uri, + indexResource, + ); + post.articleBody = message; + if (uploadedImage) { + post.image = { "@id": uploadedImage.uri }; + } + if (session.webId) { + post.publisher = { "@id": session.webId }; + } + post.type = { "@id": "SocialMediaPosting" }; + post.uploadDate = new Date().toISOString(); + const result = await commitData(post); + if (result.type === "error") { + alert(result.message); + } + + // Clear the UI after Upload + setMessage(""); + setSelectedFile(undefined); + if (fileInputRef.current) fileInputRef.current.value = ""; + }, + [message, selectedFile, session.webId], + ); + + return ( +
+ setMessage(e.target.value)} + /> + setSelectedFile(e.target.files?.[0])} + /> + +
+ ); }; diff --git a/packages/ldo/src/methods.ts b/packages/ldo/src/methods.ts index dfcc4b9..418aaa7 100644 --- a/packages/ldo/src/methods.ts +++ b/packages/ldo/src/methods.ts @@ -57,7 +57,7 @@ export function commitTransaction(ldo: LdoBase): void { }); } -export function transactionChanges(ldo: LdoBase): DatasetChanges { +export function transactionChanges(ldo: LdoBase): DatasetChanges { const [dataset] = getTransactionalDatasetFromLdo(ldo); return dataset.getChanges(); } diff --git a/packages/solid-react/src/SolidLdoProvider.tsx b/packages/solid-react/src/SolidLdoProvider.tsx index 9aae7b1..6f928ef 100644 --- a/packages/solid-react/src/SolidLdoProvider.tsx +++ b/packages/solid-react/src/SolidLdoProvider.tsx @@ -8,25 +8,16 @@ import { import { useSolidAuth } from "./SolidAuthContext"; import type { SolidLdoDataset } from "@ldo/solid"; import { createSolidLdoDataset } from "@ldo/solid"; -import type { LdoBase, ShapeType } from "@ldo/ldo"; -import type { SubjectNode } from "@ldo/rdf-utils"; +import type { UseLdoMethods } from "./useLdoMethods"; +import { createUseLdoMethods } from "./useLdoMethods"; export const SolidLdoReactContext = // This will be set in the provider // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore - createContext(undefined); + createContext(undefined); -export interface UseLdoResult { - dataset: SolidLdoDataset; - getResource: SolidLdoDataset["getResource"]; - getSubject( - shapeType: ShapeType, - subject: string | SubjectNode, - ): Type | Error; -} - -export function useLdo(): UseLdoResult { +export function useLdo(): UseLdoMethods { return useContext(SolidLdoReactContext); } @@ -50,17 +41,8 @@ export const SolidLdoProvider: FunctionComponent = ({ solidLdoDataset.context.fetch = fetch; }, [fetch]); - const value: UseLdoResult = useMemo( - () => ({ - dataset: solidLdoDataset, - getResource: solidLdoDataset.getResource.bind(solidLdoDataset), - getSubject( - shapeType: ShapeType, - subject: string | SubjectNode, - ): Type | Error { - return solidLdoDataset.usingType(shapeType).fromSubject(subject); - }, - }), + const value: UseLdoMethods = useMemo( + () => createUseLdoMethods(solidLdoDataset), [solidLdoDataset], ); diff --git a/packages/solid-react/src/index.ts b/packages/solid-react/src/index.ts index b7e3b83..005bdcc 100644 --- a/packages/solid-react/src/index.ts +++ b/packages/solid-react/src/index.ts @@ -3,5 +3,6 @@ export * from "./SolidAuthContext"; export { useLdo } from "./SolidLdoProvider"; -// documentHooks +// hooks export * from "./useResource"; +export * from "./useSubject"; diff --git a/packages/solid-react/src/useLdoMethods.ts b/packages/solid-react/src/useLdoMethods.ts new file mode 100644 index 0000000..102d94c --- /dev/null +++ b/packages/solid-react/src/useLdoMethods.ts @@ -0,0 +1,91 @@ +import type { LdoBase, ShapeType } from "@ldo/ldo"; +import { transactionChanges } from "@ldo/ldo"; +import { write } from "@ldo/ldo"; +import { startTransaction } from "@ldo/ldo"; +import type { SubjectNode } from "@ldo/rdf-utils"; +import type { Resource, SolidLdoDataset } from "@ldo/solid"; + +export interface UseLdoMethods { + dataset: SolidLdoDataset; + getResource: SolidLdoDataset["getResource"]; + getSubject( + shapeType: ShapeType, + subject: string | SubjectNode, + ): Type | Error; + createData( + shapeType: ShapeType, + subject: string | SubjectNode, + ...resources: Resource[] + ): Type; + changeData(input: Type, ...resources: Resource[]): Type; + commitData(input: LdoBase): ReturnType; +} + +export function createUseLdoMethods(dataset: SolidLdoDataset): UseLdoMethods { + return { + dataset: dataset, + /** + * Gets a resource + */ + getResource: dataset.getResource.bind(dataset), + /** + * Returns a Linked Data Object for a subject + * @param shapeType The shape type for the data + * @param subject Subject Node + * @returns A Linked Data Object + */ + getSubject( + shapeType: ShapeType, + subject: string | SubjectNode, + ): Type | Error { + return dataset.usingType(shapeType).fromSubject(subject); + }, + /** + * Begins tracking changes to eventually commit for a new subject + * @param shapeType The shape type that defines the created data + * @param subject The RDF subject for a Linked Data Object + * @param resources Any number of resources to which this data should be written + * @returns A Linked Data Object to modify and commit + */ + createData( + shapeType: ShapeType, + subject: string | SubjectNode, + ...resources: Resource[] + ): Type { + const linkedDataObject = dataset + .usingType(shapeType) + .write(...resources.map((r) => r.uri)) + .fromSubject(subject); + startTransaction(linkedDataObject); + return linkedDataObject; + }, + /** + * Begins tracking changes to eventually commit + * @param input A linked data object to track changes on + * @param resources + */ + changeData( + input: Type, + ...resources: Resource[] + ): Type { + // Clone the input and set a graph + const [transactionLdo] = write(...resources.map((r) => r.uri)).usingCopy( + input, + ); + // Start a transaction with the input + startTransaction(transactionLdo); + // Return + return transactionLdo; + }, + /** + * Commits the transaction to the global dataset, syncing all subscribing + * components and Solid Pods + */ + commitData( + input: LdoBase, + ): ReturnType { + const changes = transactionChanges(input); + return dataset.commitChangesToPod(changes); + }, + }; +} diff --git a/packages/solid-react/src/useSubject.ts b/packages/solid-react/src/useSubject.ts index e69de29..a748c30 100644 --- a/packages/solid-react/src/useSubject.ts +++ b/packages/solid-react/src/useSubject.ts @@ -0,0 +1,57 @@ +import type { SubjectNode } from "@ldo/rdf-utils"; +import { + ContextUtil, + JsonldDatasetProxyBuilder, +} from "@ldo/jsonld-dataset-proxy"; +import type { ShapeType } from "@ldo/ldo"; +import { LdoBuilder } from "@ldo/ldo"; +import type { LdoBase } from "@ldo/ldo"; +import { useLdo } from "./SolidLdoProvider"; +import { useCallback, useEffect, useMemo, useState } from "react"; +import { TrackingProxyContext } from "./util/TrackingProxyContext"; +import { defaultGraph } from "@rdfjs/data-model"; + +export function useSubject( + shapeType: ShapeType, + subject: string | SubjectNode, +): Type { + const { dataset } = useLdo(); + + const [forceUpdateCounter, setForceUpdateCounter] = useState(0); + const forceUpdate = useCallback( + () => setForceUpdateCounter((val) => val + 1), + [], + ); + + // The main linked data object + const linkedDataObject = useMemo(() => { + // Remove all current subscriptions + dataset.removeListenerFromAllEvents(forceUpdate); + + // Rebuild the LdoBuilder from scratch to inject TrackingProxyContext + const contextUtil = new ContextUtil(shapeType.context); + const proxyContext = new TrackingProxyContext( + { + dataset, + contextUtil, + writeGraphs: [defaultGraph()], + languageOrdering: ["none", "en", "other"], + }, + forceUpdate, + ); + const builder = new LdoBuilder( + new JsonldDatasetProxyBuilder(proxyContext), + shapeType, + ); + return builder.fromSubject(subject); + }, [shapeType, subject, dataset, forceUpdateCounter, forceUpdate]); + + useEffect(() => { + // Unregister force update listener upon unmount + return () => { + dataset.removeListenerFromAllEvents(forceUpdate); + }; + }, [shapeType, subject]); + + return linkedDataObject; +} diff --git a/packages/solid/src/SolidLdoDataset.ts b/packages/solid/src/SolidLdoDataset.ts index 75c5003..78437b8 100644 --- a/packages/solid/src/SolidLdoDataset.ts +++ b/packages/solid/src/SolidLdoDataset.ts @@ -1,11 +1,17 @@ import { LdoDataset } from "@ldo/ldo"; -import type { Dataset, DatasetFactory } from "@rdfjs/types"; +import type { DatasetChanges, GraphNode } from "@ldo/rdf-utils"; +import type { Dataset, DatasetFactory, Quad } from "@rdfjs/types"; +import { CommitChangesSuccess } from "./requester/requestResults/CommitChangesSuccess"; +import { InvalidUriError } from "./requester/requestResults/DataResult"; +import { AggregateError } from "./requester/requestResults/ErrorResult"; +import type { UpdateResultError } from "./requester/requests/updateDataResource"; import type { Container } from "./resource/Container"; import type { Leaf } from "./resource/Leaf"; -import type { Resource } from "./resource/Resource"; import type { ResourceGetterOptions } from "./ResourceStore"; import type { SolidLdoDatasetContext } from "./SolidLdoDatasetContext"; +import { splitChangesByGraph } from "./util/splitChangesByGraph"; import type { ContainerUri, LeafUri } from "./util/uriTypes"; +import { isContainerUri } from "./util/uriTypes"; export class SolidLdoDataset extends LdoDataset { public context: SolidLdoDatasetContext; @@ -25,4 +31,56 @@ export class SolidLdoDataset extends LdoDataset { getResource(uri: string, options?: ResourceGetterOptions): Leaf | Container { return this.context.resourceStore.get(uri, options); } + + async commitChangesToPod( + changes: DatasetChanges, + ): Promise< + CommitChangesSuccess | AggregateError + > { + const changesByGraph = splitChangesByGraph(changes); + const results: [ + GraphNode, + DatasetChanges, + UpdateResultError | InvalidUriError | Leaf | { type: "defaultGraph" }, + ][] = await Promise.all( + Array.from(changesByGraph.entries()).map( + async ([graph, datasetChanges]) => { + if (graph.termType === "DefaultGraph") { + // Undefined means that this is the default graph + this.bulk(datasetChanges); + return [graph, datasetChanges, { type: "defaultGraph" }]; + } + if (isContainerUri(graph.value)) { + return [ + graph, + datasetChanges, + new InvalidUriError( + graph.value, + `Container URIs are not allowed for custom data.`, + ), + ]; + } + const resource = this.getResource(graph.value as LeafUri); + return [graph, datasetChanges, await resource.update(datasetChanges)]; + }, + ), + ); + + // If one has errored, return error + const errors = results.filter((result) => result[2].type === "error"); + if (errors.length > 0) { + return new AggregateError( + "", + errors.map( + (result) => result[2] as UpdateResultError | InvalidUriError, + ), + ); + } + return new CommitChangesSuccess( + "", + results + .map((result) => result[2]) + .filter((result): result is Leaf => result.type === "leaf"), + ); + } } diff --git a/packages/solid/src/requester/LeafRequester.ts b/packages/solid/src/requester/LeafRequester.ts index 63817d8..b35cfb4 100644 --- a/packages/solid/src/requester/LeafRequester.ts +++ b/packages/solid/src/requester/LeafRequester.ts @@ -1,3 +1,42 @@ +import type { DatasetChanges } from "@ldo/rdf-utils"; +import { mergeDatasetChanges } from "@ldo/subscribable-dataset"; +import type { Quad } from "@rdfjs/types"; import { Requester } from "./Requester"; +import type { UpdateResult } from "./requests/updateDataResource"; +import { updateDataResource } from "./requests/updateDataResource"; -export class LeafRequester extends Requester {} +export const UPDATE_KEY = "update"; + +export class LeafRequester extends Requester { + isUpdating(): boolean { + return this.requestBatcher.isLoading(UPDATE_KEY); + } + + /** + * Update the data on this resource + * @param changes + */ + async updateDataResource( + changes: DatasetChanges, + ): Promise { + const result = await this.requestBatcher.queueProcess({ + name: UPDATE_KEY, + args: [ + { uri: this.uri, fetch: this.context.fetch }, + changes, + this.context.solidLdoDataset, + ], + perform: updateDataResource, + modifyQueue: (queue, isLoading, [, changes]) => { + if (queue[queue.length - 1].name === UPDATE_KEY) { + // Merge Changes + const originalChanges = queue[queue.length - 1].args[1]; + mergeDatasetChanges(originalChanges, changes); + return true; + } + return false; + }, + }); + return result; + } +} diff --git a/packages/solid/src/requester/Requester.ts b/packages/solid/src/requester/Requester.ts index 5513753..64e871a 100644 --- a/packages/solid/src/requester/Requester.ts +++ b/packages/solid/src/requester/Requester.ts @@ -1,6 +1,5 @@ import { ANY_KEY, RequestBatcher } from "../util/RequestBatcher"; import type { SolidLdoDatasetContext } from "../SolidLdoDatasetContext"; -import type { DatasetChanges } from "@ldo/rdf-utils"; import type { CreateResult, CreateResultWithoutOverwrite, @@ -15,12 +14,10 @@ import type { import { uploadResource } from "./requests/uploadResource"; import type { DeleteResult } from "./requests/deleteResource"; import { deleteResource } from "./requests/deleteResource"; -import type { UpdateResult } from "./requests/updateDataResource"; const READ_KEY = "read"; const CREATE_KEY = "createDataResource"; const UPLOAD_KEY = "upload"; -const UPDATE_KEY = "updateDataREsource"; const DELETE_KEY = "delete"; export abstract class Requester { @@ -47,9 +44,6 @@ export abstract class Requester { isReading(): boolean { return this.requestBatcher.isLoading(READ_KEY); } - isUpdating(): boolean { - return this.requestBatcher.isLoading(UPDATE_KEY); - } isDeletinng(): boolean { return this.requestBatcher.isLoading(DELETE_KEY); } @@ -166,14 +160,6 @@ export abstract class Requester { return result; } - /** - * Update the data on this resource - * @param changes - */ - updateDataResource(_changes: DatasetChanges): Promise { - throw new Error("Not Implemented"); - } - /** * Delete this resource */ diff --git a/packages/solid/src/requester/requestResults/CommitChangesSuccess.ts b/packages/solid/src/requester/requestResults/CommitChangesSuccess.ts new file mode 100644 index 0000000..8a1cf4d --- /dev/null +++ b/packages/solid/src/requester/requestResults/CommitChangesSuccess.ts @@ -0,0 +1,12 @@ +import type { Container } from "../../resource/Container"; +import type { Leaf } from "../../resource/Leaf"; +import { RequesterResult } from "./RequesterResult"; + +export class CommitChangesSuccess extends RequesterResult { + readonly type = "commitChangesSuccess" as const; + readonly affectedResources: (Leaf | Container)[]; + constructor(uri: string, affectedResources: (Leaf | Container)[]) { + super(uri); + this.affectedResources = affectedResources; + } +} diff --git a/packages/solid/src/requester/requestResults/DataResult.ts b/packages/solid/src/requester/requestResults/DataResult.ts index 6662a59..bd031c9 100644 --- a/packages/solid/src/requester/requestResults/DataResult.ts +++ b/packages/solid/src/requester/requestResults/DataResult.ts @@ -12,4 +12,16 @@ export class DataResult extends RequesterResult { export class TurtleFormattingError extends ErrorResult { errorType = "turtleFormatting" as const; + + constructor(uri: string, message?: string) { + super(uri, message || `Problem parsing turtle for ${uri}`); + } +} + +export class InvalidUriError extends ErrorResult { + errorType = "invalidUri" as const; + + constructor(uri: string, message?: string) { + super(uri, message || `${uri} is not a valid uri.`); + } } diff --git a/packages/solid/src/requester/requests/createDataResource.ts b/packages/solid/src/requester/requests/createDataResource.ts index 2932d8b..207d2ad 100644 --- a/packages/solid/src/requester/requests/createDataResource.ts +++ b/packages/solid/src/requester/requests/createDataResource.ts @@ -58,7 +58,6 @@ export async function createDataResource( } // Create the document const parentUri = getParentUri(uri)!; - console.log("This is the URI", uri); const headers: HeadersInit = { "content-type": "text/turtle", slug: getSlug(uri), diff --git a/packages/solid/src/requester/requests/getAccessRules.ts b/packages/solid/src/requester/requests/getAccessRules.ts index f62ee13..144501d 100644 --- a/packages/solid/src/requester/requests/getAccessRules.ts +++ b/packages/solid/src/requester/requests/getAccessRules.ts @@ -1,14 +1,12 @@ -import { universalAccess } from "@inrupt/solid-client"; import type { AccessRuleFetchError, AccessRuleResult, } from "../requestResults/AccessRule"; import type { SimpleRequestParams } from "./requestParams"; -export async function getAccessRules({ - uri, - fetch, -}: SimpleRequestParams): Promise { +export async function getAccessRules( + _params: SimpleRequestParams, +): Promise { throw new Error("Not Implemented"); // const [publicAccess, agentAccess] = await Promise.all([ // universalAccess.getPublicAccess(uri, { fetch }), diff --git a/packages/solid/src/requester/requests/updateDataResource.ts b/packages/solid/src/requester/requests/updateDataResource.ts index 2e345fb..3671342 100644 --- a/packages/solid/src/requester/requests/updateDataResource.ts +++ b/packages/solid/src/requester/requests/updateDataResource.ts @@ -1,15 +1,45 @@ import type { DatasetChanges } from "@ldo/rdf-utils"; -import type { DataResult } from "../requestResults/DataResult"; +import { changesToSparqlUpdate } from "@ldo/rdf-utils"; +import { DataResult } from "../requestResults/DataResult"; import type { HttpErrorResultType } from "../requestResults/HttpErrorResult"; -import type { UnexpectedError } from "../requestResults/ErrorResult"; -import type { RequestParams } from "./requestParams"; +import { HttpErrorResult } from "../requestResults/HttpErrorResult"; +import { UnexpectedError } from "../requestResults/ErrorResult"; +import type { SimpleRequestParams } from "./requestParams"; +import type { SubscribableDataset } from "@ldo/subscribable-dataset"; +import type { Quad } from "@rdfjs/types"; export type UpdateResult = DataResult | UpdateResultError; export type UpdateResultError = HttpErrorResultType | UnexpectedError; export async function updateDataResource( - _params: RequestParams, - _datasetChanges: DatasetChanges, + { uri, fetch }: SimpleRequestParams, + datasetChanges: DatasetChanges, + mainDataset: SubscribableDataset, ): Promise { - throw new Error("Not Implemented"); + try { + // Put Changes in transactional dataset + const transaction = mainDataset.startTransaction(); + transaction.addAll(datasetChanges.added || []); + datasetChanges.removed?.forEach((quad) => transaction.delete(quad)); + // Commit data optimistically + transaction.commit(); + // Make request + const sparqlUpdate = await changesToSparqlUpdate(datasetChanges); + const response = await fetch(uri, { + method: "PATCH", + body: sparqlUpdate, + headers: { + "Content-Type": "application/sparql-update", + }, + }); + const httpError = HttpErrorResult.checkResponse(uri, response); + if (httpError) { + // Handle error rollback + transaction.rollback(); + return httpError; + } + return new DataResult(uri); + } catch (err) { + return UnexpectedError.fromThrown(uri, err); + } } diff --git a/packages/solid/src/resource/Container.ts b/packages/solid/src/resource/Container.ts index 2591eeb..301c040 100644 --- a/packages/solid/src/resource/Container.ts +++ b/packages/solid/src/resource/Container.ts @@ -11,6 +11,10 @@ import type { } from "../requester/requests/createDataResource"; import type { DeleteResultError } from "../requester/requests/deleteResource"; import type { ReadResultError } from "../requester/requests/readResource"; +import type { + UploadResultError, + UploadResultWithoutOverwriteError, +} from "../requester/requests/uploadResource"; import type { SolidLdoDatasetContext } from "../SolidLdoDatasetContext"; import { getParentUri, ldpContains } from "../util/rdfUtils"; import type { ContainerUri, LeafUri } from "../util/uriTypes"; @@ -89,6 +93,13 @@ export class Container extends Resource { }); } + child(slug: ContainerUri): Container; + child(slug: LeafUri): Leaf; + child(slug: string): Leaf | Container; + child(slug: string): Leaf | Container { + return this.context.resourceStore.get(`${this.uri}${slug}`); + } + createChildAndOverwrite( slug: ContainerUri, ): Promise; @@ -97,8 +108,7 @@ export class Container extends Resource { createChildAndOverwrite( slug: string, ): Promise { - const resource = this.context.resourceStore.get(`${this.uri}${slug}`); - return resource.createAndOverwrite(); + return this.child(slug).createAndOverwrite(); } createChildIfAbsent( @@ -109,12 +119,41 @@ export class Container extends Resource { ): Promise; createChildIfAbsent( slug: string, - ): Promise; + ): Promise; createChildIfAbsent( slug: string, - ): Promise { - const resource = this.context.resourceStore.get(`${this.uri}${slug}`); - return resource.createIfAbsent(); + ): Promise { + return this.child(slug).createIfAbsent(); + } + + async uploadChildAndOverwrite( + slug: string, + blob: Blob, + mimeType: string, + ): Promise { + const child = this.child(slug); + if (child.type === "leaf") { + return child.uploadAndOverwrite(blob, mimeType); + } + return new UnexpectedError( + child.uri, + new Error(`${slug} is not a leaf uri.`), + ); + } + + async uploadIfAbsent( + slug: string, + blob: Blob, + mimeType: string, + ): Promise { + const child = this.child(slug); + if (child.type === "leaf") { + return child.uploadIfAbsent(blob, mimeType); + } + return new UnexpectedError( + child.uri, + new Error(`${slug} is not a leaf uri.`), + ); } async clear(): Promise< diff --git a/packages/solid/src/resource/Leaf.ts b/packages/solid/src/resource/Leaf.ts index b9faa97..012041a 100644 --- a/packages/solid/src/resource/Leaf.ts +++ b/packages/solid/src/resource/Leaf.ts @@ -1,6 +1,6 @@ import type { DatasetChanges } from "@ldo/rdf-utils"; +import type { Quad } from "@rdfjs/types"; import { LeafRequester } from "../requester/LeafRequester"; -import type { Requester } from "../requester/Requester"; import type { AbsentResult } from "../requester/requestResults/AbsentResult"; import type { BinaryResult } from "../requester/requestResults/BinaryResult"; import type { DataResult } from "../requester/requestResults/DataResult"; @@ -20,7 +20,7 @@ import { Resource } from "./Resource"; export class Leaf extends Resource { readonly uri: LeafUri; - protected requester: Requester; + protected requester: LeafRequester; readonly type = "leaf" as const; protected binaryData: { data: Blob; mimeType: string } | undefined; @@ -31,6 +31,10 @@ export class Leaf extends Resource { this.requester = new LeafRequester(uri, context); } + isUpdating(): boolean { + return this.requester.isUpdating(); + } + protected parseResult( result: AbsentResult | BinaryResult | DataResult | PossibleErrors, ): this | PossibleErrors { @@ -84,8 +88,10 @@ export class Leaf extends Resource { return this.parseResult(await this.requester.upload(blob, mimeType)); } - update(_changes: DatasetChanges): Promise { - throw new Error("Method not implemented"); + async update( + changes: DatasetChanges, + ): Promise { + return this.parseResult(await this.requester.updateDataResource(changes)); } // Delete Method diff --git a/packages/solid/src/resource/Resource.ts b/packages/solid/src/resource/Resource.ts index 64e4b11..d9b00d1 100644 --- a/packages/solid/src/resource/Resource.ts +++ b/packages/solid/src/resource/Resource.ts @@ -21,6 +21,7 @@ import { getAccessRules } from "../requester/requests/getAccessRules"; import { setAccessRules } from "../requester/requests/setAccessRules"; import type TypedEmitter from "typed-emitter"; import EventEmitter from "events"; +import { getParentUri } from "../util/rdfUtils"; export abstract class Resource extends (EventEmitter as new () => TypedEmitter<{ update: () => void; @@ -51,9 +52,6 @@ export abstract class Resource extends (EventEmitter as new () => TypedEmitter<{ isReading(): boolean { return this.requester.isReading(); } - isUpdating(): boolean { - return this.requester.isUpdating(); - } isDeleting(): boolean { return this.requester.isDeletinng(); } @@ -78,26 +76,24 @@ export abstract class Resource extends (EventEmitter as new () => TypedEmitter<{ protected parseResult( result: AbsentResult | BinaryResult | DataResult | PossibleErrors, ): this | PossibleErrors { - let toReturn: this | PossibleErrors; - switch (result.type) { - case "error": - toReturn = result; - break; - case "absent": - this.didInitialFetch = true; - this.absent = true; - // eslint-disable-next-line @typescript-eslint/no-this-alias - toReturn = this; - break; - default: - this.didInitialFetch = true; - this.absent = false; - // eslint-disable-next-line @typescript-eslint/no-this-alias - toReturn = this; - break; + if (result.type === "error") { + return result; + } + + if (result.type === "absent") { + this.didInitialFetch = true; + this.absent = true; + } else { + this.didInitialFetch = true; + this.absent = false; } this.emit("update"); - return toReturn; + const parentUri = getParentUri(this.uri); + if (parentUri) { + const parentContainer = this.context.resourceStore.get(parentUri); + parentContainer.emit("update"); + } + return this; } // Read Methods diff --git a/packages/solid/src/util/uriTypes.ts b/packages/solid/src/util/uriTypes.ts index 1fadb24..a5a3d6c 100644 --- a/packages/solid/src/util/uriTypes.ts +++ b/packages/solid/src/util/uriTypes.ts @@ -8,7 +8,7 @@ export function isContainerUri(uri: string): uri is ContainerUri { } export function isLeafUri(uri: string): uri is LeafUri { - return !isContainerUri; + return !isContainerUri(uri); } type NonPathnameEnding = "" | `?${string}` | `#${string}`; diff --git a/packages/subscribable-dataset/src/ProxyTransactionalDataset.ts b/packages/subscribable-dataset/src/ProxyTransactionalDataset.ts index 81502f1..f01eceb 100644 --- a/packages/subscribable-dataset/src/ProxyTransactionalDataset.ts +++ b/packages/subscribable-dataset/src/ProxyTransactionalDataset.ts @@ -2,6 +2,7 @@ import type { Dataset, BaseQuad, Term, DatasetFactory } from "@rdfjs/types"; import type { DatasetChanges } from "@ldo/rdf-utils"; import type { BulkEditableDataset, TransactionalDataset } from "./types"; import { ExtendedDataset } from "@ldo/dataset"; +import { mergeDatasetChanges } from "./mergeDatasetChanges"; /** * Proxy Transactional Dataset is a transactional dataset that does not duplicate @@ -240,48 +241,14 @@ export default class ProxyTransactionalDataset< }): void { this.checkIfTransactionCommitted(); - // Add added - if (changes.added) { - if (this.datasetChanges.added) { - this.datasetChanges.added.addAll(changes.added); - } else { - this.datasetChanges.added = this.datasetFactory.dataset(changes.added); - } - // Delete from removed if present - const changesIntersection = this.datasetChanges.removed?.intersection( - this.datasetFactory.dataset(changes.added), - ); - if (changesIntersection && changesIntersection.size > 0) { - this.datasetChanges.removed = - this.datasetChanges.removed?.difference(changesIntersection); - } - } - // Add removed - if (changes.removed) { - if (this.datasetChanges.removed) { - this.datasetChanges.removed.addAll(changes.removed); - } else { - this.datasetChanges.removed = this.datasetFactory.dataset( - changes.removed, - ); - } - // Delete from added if present - const changesIntersection = this.datasetChanges.added?.intersection( - this.datasetFactory.dataset(changes.removed), - ); - if (changesIntersection && changesIntersection.size > 0) { - this.datasetChanges.added = - this.datasetChanges.added?.difference(changesIntersection); - } - } - - // Make undefined if size is zero - if (this.datasetChanges.added && this.datasetChanges.added.size === 0) { - this.datasetChanges.added = undefined; - } - if (this.datasetChanges.removed && this.datasetChanges.removed.size === 0) { - this.datasetChanges.removed = undefined; - } + mergeDatasetChanges(this.datasetChanges, { + added: changes.added + ? this.datasetFactory.dataset(changes.added) + : undefined, + removed: changes.removed + ? this.datasetFactory.dataset(changes.removed) + : undefined, + }); } /** diff --git a/packages/subscribable-dataset/src/index.ts b/packages/subscribable-dataset/src/index.ts index 0eb99f4..b718c4c 100644 --- a/packages/subscribable-dataset/src/index.ts +++ b/packages/subscribable-dataset/src/index.ts @@ -7,3 +7,4 @@ export { default as ProxyTransactionalDataset } from "./ProxyTransactionalDatase export { default as WrapperSubscribableDataset } from "./WrapperSubscribableDataset"; export { default as WrapperSubscribableDatasetFactory } from "./WrapperSubscribableDatasetFactory"; export * from "./types"; +export * from "./mergeDatasetChanges"; diff --git a/packages/subscribable-dataset/src/mergeDatasetChanges.ts b/packages/subscribable-dataset/src/mergeDatasetChanges.ts new file mode 100644 index 0000000..f62cade --- /dev/null +++ b/packages/subscribable-dataset/src/mergeDatasetChanges.ts @@ -0,0 +1,53 @@ +import type { DatasetChanges } from "@ldo/rdf-utils"; +import type { BaseQuad } from "@rdfjs/types"; + +/** + * Merges a new change into an original change + * @param originalChange + * @param newChange + */ +export function mergeDatasetChanges( + originalChange: DatasetChanges, + newChange: DatasetChanges, +): void { + // Add added + if (newChange.added) { + if (originalChange.added) { + originalChange.added.addAll(newChange.added); + } else { + originalChange.added = newChange.added; + } + // Delete from removed if present + const changesIntersection = originalChange.removed?.intersection( + newChange.added, + ); + if (changesIntersection && changesIntersection.size > 0) { + originalChange.removed = + originalChange.removed?.difference(changesIntersection); + } + } + // Add removed + if (newChange.removed) { + if (originalChange.removed) { + originalChange.removed.addAll(newChange.removed); + } else { + originalChange.removed = newChange.removed; + } + // Delete from added if present + const changesIntersection = originalChange.added?.intersection( + newChange.removed, + ); + if (changesIntersection && changesIntersection.size > 0) { + originalChange.added = + originalChange.added?.difference(changesIntersection); + } + } + + // Make undefined if size is zero + if (originalChange.added && originalChange.added.size === 0) { + originalChange.added = undefined; + } + if (originalChange.removed && originalChange.removed.size === 0) { + originalChange.removed = undefined; + } +}