UNPKG

@inrupt/solid-client

Version:
882 lines (804 loc) 31.6 kB
/** * Copyright 2020 Inrupt Inc. * * Permission is hereby granted, free of charge, to any person obtaining a copy * of this software and associated documentation files (the "Software"), to deal in * the Software without restriction, including without limitation the rights to use, * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the * Software, and to permit persons to whom the Software is furnished to do so, * subject to the following conditions: * * The above copyright notice and this permission notice shall be included in * all copies or substantial portions of the Software. * * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, * INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A * PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION * OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE * SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ import { Quad, NamedNode, Quad_Object } from "rdf-js"; import { dataset, DataFactory } from "../rdfjs"; import { ldp } from "../constants"; import { turtleToTriples, triplesToTurtle } from "../formats/turtle"; import { isLocalNode, isNamedNode, resolveIriForLocalNode, resolveIriForLocalNodes, } from "../datatypes"; import { UrlString, SolidDataset, WithChangeLog, hasChangelog, WithResourceInfo, hasResourceInfo, LocalNode, Url, IriString, Thing, WithServerResourceInfo, } from "../interfaces"; import { internal_toIriString } from "../interfaces.internal"; import { internal_defaultFetchOptions, getSourceUrl, getResourceInfo, isContainer, FetchError, } from "./resource"; import { internal_cloneResource, internal_isUnsuccessfulResponse, internal_parseResourceInfo, } from "./resource.internal"; import { thingAsMarkdown, getThingAll, getThing } from "../thing/thing"; import { internal_getReadableValue, internal_toNode, internal_withChangeLog, } from "../thing/thing.internal"; import { getIriAll } from "../thing/get"; import { normalizeServerSideIri } from "./iri.internal"; /** * Initialise a new [[SolidDataset]] in memory. * * @returns An empty [[SolidDataset]]. */ export function createSolidDataset(): SolidDataset { return dataset(); } /** * Fetch a SolidDataset from the given URL. Currently requires the SolidDataset to be available as [Turtle](https://www.w3.org/TR/turtle/). * * @param url URL to fetch a [[SolidDataset]] from. * @param options Optional parameter `options.fetch`: An alternative `fetch` function to make the HTTP request, compatible with the browser-native [fetch API](https://developer.mozilla.org/docs/Web/API/WindowOrWorkerGlobalScope/fetch#parameters). * @returns Promise resolving to a [[SolidDataset]] containing the data at the given Resource, or rejecting if fetching it failed. */ export async function getSolidDataset( url: UrlString | Url, options: Partial< typeof internal_defaultFetchOptions > = internal_defaultFetchOptions ): Promise<SolidDataset & WithServerResourceInfo> { url = internal_toIriString(url); const config = { ...internal_defaultFetchOptions, ...options, }; const response = await config.fetch(url, { headers: { Accept: "text/turtle", }, }); if (internal_isUnsuccessfulResponse(response)) { throw new FetchError( `Fetching the Resource at [${url}] failed: [${response.status}] [${response.statusText}].`, response ); } const data = await response.text(); const triples = await turtleToTriples(data, url); const resource = dataset(); triples.forEach((triple) => resource.add(triple)); const resourceInfo = internal_parseResourceInfo(response); const resourceWithResourceInfo: SolidDataset & WithServerResourceInfo = Object.assign(resource, { internal_resourceInfo: resourceInfo, }); return resourceWithResourceInfo; } type UpdateableDataset = SolidDataset & WithChangeLog & WithServerResourceInfo & { internal_resourceInfo: { sourceIri: IriString } }; /** * Create a SPARQL UPDATE Patch request from a [[SolidDataset]] with a changelog. * @param solidDataset the [[SolidDataset]] that has been locally updated, and that should be persisted. * @returns an HTTP PATCH request configuration object, aligned with the [fetch API](https://developer.mozilla.org/docs/Web/API/WindowOrWorkerGlobalScope/fetch#parameters), containing a SPARQL UPDATE. * @hidden */ async function prepareSolidDatasetUpdate( solidDataset: UpdateableDataset ): Promise<RequestInit> { const deleteStatement = solidDataset.internal_changeLog.deletions.length > 0 ? `DELETE DATA {${( await triplesToTurtle( solidDataset.internal_changeLog.deletions.map( getNamedNodesForLocalNodes ) ) ).trim()}};` : ""; const insertStatement = solidDataset.internal_changeLog.additions.length > 0 ? `INSERT DATA {${( await triplesToTurtle( solidDataset.internal_changeLog.additions.map( getNamedNodesForLocalNodes ) ) ).trim()}};` : ""; return { method: "PATCH", body: `${deleteStatement} ${insertStatement}`, headers: { "Content-Type": "application/sparql-update", }, }; } /** * Create a Put request to write a locally created [[SolidDataset]] to a Pod. * @param solidDataset the [[SolidDataset]] that has been locally updated, and that should be persisted. * @returns an HTTP PUT request configuration object, aligned with the [fetch API](https://developer.mozilla.org/docs/Web/API/WindowOrWorkerGlobalScope/fetch#parameters), containing a serialization of the [[SolidDataset]]. * @hidden */ async function prepareSolidDatasetCreation( solidDataset: SolidDataset ): Promise<RequestInit> { return { method: "PUT", body: await triplesToTurtle( Array.from(solidDataset).map(getNamedNodesForLocalNodes) ), headers: { "Content-Type": "text/turtle", "If-None-Match": "*", Link: `<${ldp.Resource}>; rel="type"`, }, }; } /** * Given a SolidDataset, store it in a Solid Pod (overwriting the existing data at the given URL). * * A SolidDataset keeps track of the data changes compared to the data in the Pod; i.e., * the changelog tracks both the old value and new values of the property being modified. This * function applies the changes to the current SolidDataset. If the old value specified in the * changelog does not correspond to the value currently in the Pod, this function will throw an * error. * The SolidDataset returned by this function will contain the data sent to the Pod, and a ChangeLog * up-to-date with the saved data. Note that if the data on the server was modified in between the * first fetch and saving it, the updated data will not be reflected in the returned SolidDataset. * To make sure you have the latest data, call [[getSolidDataset]] again after saving the data. * * The Solid server will create any intermediary Containers that do not exist yet, so they do not * need to be created in advance. For example, if the target URL is * https://example.pod/container/resource and https://example.pod/container/ does not exist yet, * it will exist after this function resolves successfully. * * @param url URL to save `solidDataset` to. * @param solidDataset The [[SolidDataset]] to save. * @param options Optional parameter `options.fetch`: An alternative `fetch` function to make the HTTP request, compatible with the browser-native [fetch API](https://developer.mozilla.org/docs/Web/API/WindowOrWorkerGlobalScope/fetch#parameters). * @returns A Promise resolving to a [[SolidDataset]] containing the stored data, or rejecting if saving it failed. */ export async function saveSolidDatasetAt<Dataset extends SolidDataset>( url: UrlString | Url, solidDataset: Dataset, options: Partial< typeof internal_defaultFetchOptions > = internal_defaultFetchOptions ): Promise<Dataset & WithServerResourceInfo & WithChangeLog> { url = internal_toIriString(url); const config = { ...internal_defaultFetchOptions, ...options, }; const datasetWithChangelog = internal_withChangeLog(solidDataset); const requestInit = isUpdate(datasetWithChangelog, url) ? await prepareSolidDatasetUpdate(datasetWithChangelog) : await prepareSolidDatasetCreation(datasetWithChangelog); const response = await config.fetch(url, requestInit); if (internal_isUnsuccessfulResponse(response)) { const diagnostics = isUpdate(datasetWithChangelog, url) ? "The changes that were sent to the Pod are listed below.\n\n" + changeLogAsMarkdown(datasetWithChangelog) : "The SolidDataset that was sent to the Pod is listed below.\n\n" + solidDatasetAsMarkdown(datasetWithChangelog); throw new FetchError( `Storing the Resource at [${url}] failed: [${response.status}] [${response.statusText}].\n\n` + diagnostics, response ); } const resourceInfo: WithServerResourceInfo["internal_resourceInfo"] = { ...internal_parseResourceInfo(response), isRawData: false, }; const storedDataset: Dataset & WithChangeLog & WithServerResourceInfo = Object.assign( internal_cloneResource(datasetWithChangelog), { internal_changeLog: { additions: [], deletions: [] }, internal_resourceInfo: resourceInfo, } ); const storedDatasetWithResolvedIris = resolveLocalIrisInSolidDataset( storedDataset ); return storedDatasetWithResolvedIris; } /** * Deletes the SolidDataset at a given URL. * * @param file The (URL of the) SolidDataset to delete * @since 0.6.0 */ export async function deleteSolidDataset( solidDataset: Url | UrlString | WithResourceInfo, options: Partial< typeof internal_defaultFetchOptions > = internal_defaultFetchOptions ): Promise<void> { const config = { ...internal_defaultFetchOptions, ...options, }; const url = hasResourceInfo(solidDataset) ? internal_toIriString(getSourceUrl(solidDataset)) : internal_toIriString(solidDataset); const response = await config.fetch(url, { method: "DELETE" }); if (internal_isUnsuccessfulResponse(response)) { throw new FetchError( `Deleting the SolidDataset at [${url}] failed: [${response.status}] [${response.statusText}].`, response ); } } /** * Create an empty Container at the given URL. * * Throws an error if creating the Container failed, e.g. because the current user does not have * permissions to, or because the Container already exists. * * Note that a Solid server will automatically create the necessary Containers when storing a * Resource; i.e. there is no need to call this function if it is immediately followed by * [[saveSolidDatasetAt]] or [[overwriteFile]]. * * @param url URL of the empty Container that is to be created. * @param options Optional parameter `options.fetch`: An alternative `fetch` function to make the HTTP request, compatible with the browser-native [fetch API](https://developer.mozilla.org/docs/Web/API/WindowOrWorkerGlobalScope/fetch#parameters). * @since 0.2.0 */ export async function createContainerAt( url: UrlString | Url, options: Partial< typeof internal_defaultFetchOptions > = internal_defaultFetchOptions ): Promise<SolidDataset & WithServerResourceInfo> { url = internal_toIriString(url); url = url.endsWith("/") ? url : url + "/"; const config = { ...internal_defaultFetchOptions, ...options, }; const response = await config.fetch(url, { method: "PUT", headers: { Accept: "text/turtle", "Content-Type": "text/turtle", "If-None-Match": "*", // This header should not be required to create a Container, // but ESS currently expects it: Link: `<${ldp.BasicContainer}>; rel="type"`, }, }); if (internal_isUnsuccessfulResponse(response)) { if ( response.status === 409 && response.statusText === "Conflict" && (await response.text()).trim() === internal_NSS_CREATE_CONTAINER_SPEC_NONCOMPLIANCE_DETECTION_ERROR_MESSAGE_TO_WORKAROUND_THEIR_ISSUE_1465 ) { return createContainerWithNssWorkaroundAt(url, options); } throw new FetchError( `Creating the empty Container at [${url}] failed: [${response.status}] [${response.statusText}].`, response ); } const resourceInfo = internal_parseResourceInfo(response); const containerDataset: SolidDataset & WithChangeLog & WithServerResourceInfo = Object.assign(dataset(), { internal_changeLog: { additions: [], deletions: [] }, internal_resourceInfo: resourceInfo, }); return containerDataset; } /** * Unfortunately Node Solid Server does not confirm to the Solid spec when it comes to Container * creation. When we make the (valid, according to the Solid protocol) request to create a * Container, NSS responds with the following exact error message. Thus, when we encounter exactly * this message, we use an NSS-specific workaround ([[createContainerWithNssWorkaroundAt]]). Both * this constant and that workaround should be removed once the NSS issue has been fixed and * no versions of NSS with the issue are in common use/supported anymore. * * @see https://github.com/solid/node-solid-server/issues/1465 * @internal */ export const internal_NSS_CREATE_CONTAINER_SPEC_NONCOMPLIANCE_DETECTION_ERROR_MESSAGE_TO_WORKAROUND_THEIR_ISSUE_1465 = "Can't write file: PUT not supported on containers, use POST instead"; /** * Unfortunately Node Solid Server does not confirm to the Solid spec when it comes to Container * creation. As a workaround, we create a dummy file _inside_ the desired Container (which should * create the desired Container on the fly), and then delete it again. * * @see https://github.com/solid/node-solid-server/issues/1465 */ const createContainerWithNssWorkaroundAt: typeof createContainerAt = async ( url, options ) => { url = internal_toIriString(url); const config = { ...internal_defaultFetchOptions, ...options, }; let existingContainer; try { existingContainer = await getResourceInfo(url, options); } catch (e) { // To create the Container, we'd want it to not exist yet. In other words, we'd expect to get // a 404 error here in the happy path - so do nothing if that's the case. if (!(e instanceof FetchError) || e.statusCode !== 404) { // (But if we get an error other than a 404, just throw that error like we usually would.) throw e; } } if (typeof existingContainer !== "undefined") { throw new Error( `The Container at [${url}] already exists, and therefore cannot be created again.` ); } const dummyUrl = url + ".dummy"; const createResponse = await config.fetch(dummyUrl, { method: "PUT", headers: { Accept: "text/turtle", "Content-Type": "text/turtle", }, }); if (internal_isUnsuccessfulResponse(createResponse)) { throw new FetchError( `Creating the empty Container at [${url}] failed: [${createResponse.status}] [${createResponse.statusText}].`, createResponse ); } await config.fetch(dummyUrl, { method: "DELETE" }); const containerInfoResponse = await config.fetch(url, { method: "HEAD" }); const resourceInfo = internal_parseResourceInfo(containerInfoResponse); const containerDataset: SolidDataset & WithChangeLog & WithServerResourceInfo = Object.assign(dataset(), { internal_changeLog: { additions: [], deletions: [] }, internal_resourceInfo: resourceInfo, }); return containerDataset; }; function isSourceIriEqualTo( dataset: SolidDataset & WithResourceInfo, iri: IriString ): boolean { return ( normalizeServerSideIri(dataset.internal_resourceInfo.sourceIri) === normalizeServerSideIri(iri) ); } function isUpdate( solidDataset: SolidDataset, url: UrlString ): solidDataset is UpdateableDataset { return ( hasChangelog(solidDataset) && hasResourceInfo(solidDataset) && typeof solidDataset.internal_resourceInfo.sourceIri === "string" && isSourceIriEqualTo(solidDataset, url) ); } type SaveInContainerOptions = Partial< typeof internal_defaultFetchOptions & { slugSuggestion: string; } >; /** * Given a SolidDataset, store it in a Solid Pod in a new Resource inside a Container. * * The Container at the given URL should already exist; if it does not, you can initialise it first * using [[createContainerAt]], or directly save the SolidDataset at the desired location using * [[saveSolidDatasetAt]]. * * This function is primarily useful if the current user does not have access to change existing files in * a Container, but is allowed to add new files; in other words, they have Append, but not Write * access to a Container. This is useful in situations where someone wants to allow others to, * for example, send notifications to their Pod, but not to view or delete existing notifications. * You can pass a suggestion for the new Resource's name, but the server may decide to give it * another name — for example, if a Resource with that name already exists inside the given * Container. * If the user does have access to write directly to a given location, [[saveSolidDatasetAt]] * will do the job just fine, and does not require the parent Container to exist in advance. * * @param containerUrl URL of the Container in which to create a new Resource. * @param solidDataset The [[SolidDataset]] to save to a new Resource in the given Container. * @param options Optional parameter `options.fetch`: An alternative `fetch` function to make the HTTP request, compatible with the browser-native [fetch API](https://developer.mozilla.org/docs/Web/API/WindowOrWorkerGlobalScope/fetch#parameters). * @returns A Promise resolving to a [[SolidDataset]] containing the saved data. The Promise rejects if the save failed. */ export async function saveSolidDatasetInContainer( containerUrl: UrlString | Url, solidDataset: SolidDataset, options: SaveInContainerOptions = internal_defaultFetchOptions ): Promise<SolidDataset & WithResourceInfo> { const config = { ...internal_defaultFetchOptions, ...options, }; containerUrl = internal_toIriString(containerUrl); const rawTurtle = await triplesToTurtle( Array.from(solidDataset).map(getNamedNodesForLocalNodes) ); const headers: RequestInit["headers"] = { "Content-Type": "text/turtle", Link: `<${ldp.Resource}>; rel="type"`, }; if (options.slugSuggestion) { headers.slug = options.slugSuggestion; } const response = await config.fetch(containerUrl, { method: "POST", body: rawTurtle, headers: headers, }); if (internal_isUnsuccessfulResponse(response)) { throw new FetchError( `Storing the Resource in the Container at [${containerUrl}] failed: [${response.status}] [${response.statusText}].\n\n` + "The SolidDataset that was sent to the Pod is listed below.\n\n" + solidDatasetAsMarkdown(solidDataset), response ); } const locationHeader = response.headers.get("Location"); if (locationHeader === null) { throw new Error( "Could not determine the location of the newly saved SolidDataset." ); } const resourceIri = new URL(locationHeader, response.url).href; const resourceInfo: WithResourceInfo = { internal_resourceInfo: { isRawData: false, sourceIri: resourceIri, }, }; const resourceWithResourceInfo: SolidDataset & WithResourceInfo = Object.assign( internal_cloneResource(solidDataset), resourceInfo ); const resourceWithResolvedIris = resolveLocalIrisInSolidDataset( resourceWithResourceInfo ); return resourceWithResolvedIris; } /** * Create an empty Container inside the Container at the given URL. * * Throws an error if creating the Container failed, e.g. because the current user does not have * permissions to. * * The Container in which to create the new Container should itself already exist. * * This function is primarily useful if the current user does not have access to change existing files in * a Container, but is allowed to add new files; in other words, they have Append, but not Write * access to a Container. This is useful in situations where someone wants to allow others to, * for example, send notifications to their Pod, but not to view or delete existing notifications. * You can pass a suggestion for the new Resource's name, but the server may decide to give it * another name — for example, if a Resource with that name already exists inside the given * Container. * If the user does have access to write directly to a given location, [[createContainerAt]] * will do the job just fine, and does not require the parent Container to exist in advance. * * @param containerUrl URL of the Container in which the empty Container is to be created. * @param options Optional parameter `options.fetch`: An alternative `fetch` function to make the HTTP request, compatible with the browser-native [fetch API](https://developer.mozilla.org/docs/Web/API/WindowOrWorkerGlobalScope/fetch#parameters). * @since 0.2.0 */ export async function createContainerInContainer( containerUrl: UrlString | Url, options: SaveInContainerOptions = internal_defaultFetchOptions ): Promise<SolidDataset & WithResourceInfo> { containerUrl = internal_toIriString(containerUrl); const config = { ...internal_defaultFetchOptions, ...options, }; const headers: RequestInit["headers"] = { "Content-Type": "text/turtle", Link: `<${ldp.BasicContainer}>; rel="type"`, }; if (options.slugSuggestion) { headers.slug = options.slugSuggestion; } const response = await config.fetch(containerUrl, { method: "POST", headers: headers, }); if (internal_isUnsuccessfulResponse(response)) { throw new FetchError( `Creating an empty Container in the Container at [${containerUrl}] failed: [${response.status}] [${response.statusText}].`, response ); } const locationHeader = response.headers.get("Location"); if (locationHeader === null) { throw new Error( "Could not determine the location of the newly created Container." ); } const resourceIri = new URL(locationHeader, response.url).href; const resourceInfo: WithResourceInfo = { internal_resourceInfo: { isRawData: false, sourceIri: resourceIri, }, }; const resourceWithResourceInfo: SolidDataset & WithResourceInfo = Object.assign(dataset(), resourceInfo); return resourceWithResourceInfo; } /** * Deletes the Container at a given URL. * * @param file The (URL of the) Container to delete * @since 0.6.0 */ export async function deleteContainer( container: Url | UrlString | WithResourceInfo, options: Partial< typeof internal_defaultFetchOptions > = internal_defaultFetchOptions ): Promise<void> { const url = hasResourceInfo(container) ? internal_toIriString(getSourceUrl(container)) : internal_toIriString(container); if (!isContainer(container)) { throw new Error( `You're trying to delete the Container at [${url}], but Container URLs should end in a \`/\`. Are you sure this is a Container?` ); } const config = { ...internal_defaultFetchOptions, ...options, }; const response = await config.fetch(url, { method: "DELETE" }); if (internal_isUnsuccessfulResponse(response)) { throw new FetchError( `Deleting the Container at [${url}] failed: [${response.status}] [${response.statusText}].`, response ); } } /** * Given a [[SolidDataset]] representing a Container (see [[isContainer]]), fetch the URLs of all * contained resources. * If the solidDataset given is not a container, or is missing resourceInfo, throw an error. * * @param solidDataset The container from which to fetch all contained Resource URLs. * @returns A list of URLs, each of which points to a contained Resource of the given SolidDataset. * @since 1.3.0 */ export function getContainedResourceUrlAll( solidDataset: SolidDataset & WithResourceInfo ): UrlString[] { const container = getThing(solidDataset, getSourceUrl(solidDataset)); // See https://www.w3.org/TR/2015/REC-ldp-20150226/#h-ldpc-http_post: // > a containment triple MUST be added to the state of the LDPC whose subject is the LDPC URI, // > whose predicate is ldp:contains and whose object is the URI for the newly created document return container !== null ? getIriAll(container, ldp.contains) : []; } /** * Gets a human-readable representation of the given SolidDataset to aid debugging. * * Note that changes to the exact format of the return value are not considered a breaking change; * it is intended to aid in debugging, not as a serialisation method that can be reliably parsed. * * @param solidDataset The [[SolidDataset]] to get a human-readable representation of. * @since 0.3.0 */ export function solidDatasetAsMarkdown(solidDataset: SolidDataset): string { let readableSolidDataset: string = ""; if (hasResourceInfo(solidDataset)) { readableSolidDataset += `# SolidDataset: ${solidDataset.internal_resourceInfo.sourceIri}\n`; } else { readableSolidDataset += `# SolidDataset (no URL yet)\n`; } const things = getThingAll(solidDataset); if (things.length === 0) { readableSolidDataset += "\n<empty>\n"; } else { things.forEach((thing) => { readableSolidDataset += "\n" + thingAsMarkdown(thing); if (hasChangelog(solidDataset)) { readableSolidDataset += "\n" + getReadableChangeLogSummary(solidDataset, thing) + "\n"; } }); } return readableSolidDataset; } /** * Gets a human-readable representation of the local changes to a Resource to aid debugging. * * Note that changes to the exact format of the return value are not considered a breaking change; * it is intended to aid in debugging, not as a serialisation method that can be reliably parsed. * * @param solidDataset The Resource of which to get a human-readable representation of the changes applied to it locally. * @since 0.3.0 */ export function changeLogAsMarkdown( solidDataset: SolidDataset & WithChangeLog ): string { if (!hasResourceInfo(solidDataset)) { return "This is a newly initialized SolidDataset, so there is no source to compare it to."; } if ( !hasChangelog(solidDataset) || (solidDataset.internal_changeLog.additions.length === 0 && solidDataset.internal_changeLog.deletions.length === 0) ) { return ( `## Changes compared to ${getSourceUrl(solidDataset)}\n\n` + `This SolidDataset has not been modified since it was fetched from ${getSourceUrl( solidDataset )}.\n` ); } let readableChangeLog = `## Changes compared to ${getSourceUrl( solidDataset )}\n`; const changeLogsByThingAndProperty = sortChangeLogByThingAndProperty( solidDataset ); Object.keys(changeLogsByThingAndProperty).forEach((thingUrl) => { readableChangeLog += `\n### Thing: ${thingUrl}\n`; const changeLogByProperty = changeLogsByThingAndProperty[thingUrl]; Object.keys(changeLogByProperty).forEach((propertyUrl) => { readableChangeLog += `\nProperty: ${propertyUrl}\n`; const deleted = changeLogByProperty[propertyUrl].deleted; const added = changeLogByProperty[propertyUrl].added; if (deleted.length > 0) { readableChangeLog += "- Removed:\n"; deleted.forEach( (deletedValue) => (readableChangeLog += ` - ${internal_getReadableValue( deletedValue )}\n`) ); } if (added.length > 0) { readableChangeLog += "- Added:\n"; added.forEach( (addedValue) => (readableChangeLog += ` - ${internal_getReadableValue( addedValue )}\n`) ); } }); }); return readableChangeLog; } function sortChangeLogByThingAndProperty( solidDataset: WithChangeLog & WithResourceInfo ) { const changeLogsByThingAndProperty: Record< UrlString, Record<UrlString, { added: Quad_Object[]; deleted: Quad_Object[] }> > = {}; solidDataset.internal_changeLog.deletions.forEach((deletion) => { const subjectNode = isLocalNode(deletion.subject) ? resolveIriForLocalNode(deletion.subject, getSourceUrl(solidDataset)) : deletion.subject; if (!isNamedNode(subjectNode) || !isNamedNode(deletion.predicate)) { return; } const thingUrl = internal_toIriString(subjectNode); const propertyUrl = internal_toIriString(deletion.predicate); changeLogsByThingAndProperty[thingUrl] ??= {}; changeLogsByThingAndProperty[thingUrl][propertyUrl] ??= { added: [], deleted: [], }; changeLogsByThingAndProperty[thingUrl][propertyUrl].deleted.push( deletion.object ); }); solidDataset.internal_changeLog.additions.forEach((addition) => { const subjectNode = isLocalNode(addition.subject) ? resolveIriForLocalNode(addition.subject, getSourceUrl(solidDataset)) : addition.subject; if (!isNamedNode(subjectNode) || !isNamedNode(addition.predicate)) { return; } const thingUrl = internal_toIriString(subjectNode); const propertyUrl = internal_toIriString(addition.predicate); changeLogsByThingAndProperty[thingUrl] ??= {}; changeLogsByThingAndProperty[thingUrl][propertyUrl] ??= { added: [], deleted: [], }; changeLogsByThingAndProperty[thingUrl][propertyUrl].added.push( addition.object ); }); return changeLogsByThingAndProperty; } function getReadableChangeLogSummary( solidDataset: WithChangeLog, thing: Thing ): string { const subject = internal_toNode(thing); const nrOfAdditions = solidDataset.internal_changeLog.additions.reduce( (count, addition) => (addition.subject.equals(subject) ? count + 1 : count), 0 ); const nrOfDeletions = solidDataset.internal_changeLog.deletions.reduce( (count, deletion) => (deletion.subject.equals(subject) ? count + 1 : count), 0 ); const additionString = nrOfAdditions === 1 ? "1 new value added" : nrOfAdditions + " new values added"; const deletionString = nrOfDeletions === 1 ? "1 value removed" : nrOfDeletions + " values removed"; return `(${additionString} / ${deletionString})`; } function getNamedNodesForLocalNodes(quad: Quad): Quad { const subject = isLocalNode(quad.subject) ? getNamedNodeFromLocalNode(quad.subject) : quad.subject; const object = isLocalNode(quad.object) ? getNamedNodeFromLocalNode(quad.object) : quad.object; return { ...quad, subject: subject, object: object, }; } function getNamedNodeFromLocalNode(localNode: LocalNode): NamedNode { return DataFactory.namedNode("#" + localNode.internal_name); } function resolveLocalIrisInSolidDataset< Dataset extends SolidDataset & WithResourceInfo >(solidDataset: Dataset): Dataset { const resourceIri = getSourceUrl(solidDataset); const unresolvedQuads = Array.from(solidDataset); unresolvedQuads.forEach((unresolvedQuad) => { const resolvedQuad = resolveIriForLocalNodes(unresolvedQuad, resourceIri); solidDataset.delete(unresolvedQuad); solidDataset.add(resolvedQuad); }); return solidDataset; }