From 1394b9cb56c38599ae9cb36af223952be046b5d9 Mon Sep 17 00:00:00 2001 From: Ruben Verborgh Date: Wed, 21 Jul 2021 19:39:46 +0200 Subject: [PATCH] feat: Add HTML container listing. --- .../converters/errors.json | 27 ++++++ .../converters/markdown.json | 10 ++ .../representation-conversion/default.json | 20 ++-- package-lock.json | 34 ++++++- package.json | 2 + src/index.ts | 1 + src/storage/LockingResourceStore.ts | 4 +- .../ContainerToTemplateConverter.ts | 80 ++++++++++++++++ src/util/StreamUtil.ts | 4 + templates/config/defaults.json | 5 +- templates/config/memory.json | 2 +- templates/container.md.hbs | 8 ++ templates/styles/main.css | 9 ++ .../ContainerToTemplateConverter.test.ts | 95 +++++++++++++++++++ 14 files changed, 277 insertions(+), 24 deletions(-) create mode 100644 config/util/representation-conversion/converters/errors.json create mode 100644 src/storage/conversion/ContainerToTemplateConverter.ts create mode 100644 templates/container.md.hbs create mode 100644 test/unit/storage/conversion/ContainerToTemplateConverter.test.ts diff --git a/config/util/representation-conversion/converters/errors.json b/config/util/representation-conversion/converters/errors.json new file mode 100644 index 000000000..c5bb4dd32 --- /dev/null +++ b/config/util/representation-conversion/converters/errors.json @@ -0,0 +1,27 @@ +{ + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^1.0.0/components/context.jsonld", + "import": [ + "files-scs:config/util/representation-conversion/converters/content-type-replacer.json", + "files-scs:config/util/representation-conversion/converters/quad-to-rdf.json", + "files-scs:config/util/representation-conversion/converters/rdf-to-quad.json", + "files-scs:config/util/representation-conversion/converters/markdown.json" + ], + "@graph": [ + { + "@id": "urn:solid-server:default:ErrorToQuadConverter", + "@type": "ErrorToQuadConverter", + }, + { + "comment": "Converts an error into a Markdown description of its details.", + "@id": "urn:solid-server:default:ErrorToTemplateConverter", + "@type": "ErrorToTemplateConverter", + "templateEngine": { + "@type": "HandlebarsTemplateEngine", + "template": "$PACKAGE_ROOT/templates/error/main.md.hbs" + }, + "templatePath": "$PACKAGE_ROOT/templates/error/descriptions/", + "extension": ".md.hbs", + "contentType": "text/markdown" + } + ] +} diff --git a/config/util/representation-conversion/converters/markdown.json b/config/util/representation-conversion/converters/markdown.json index bedf37d32..779845494 100644 --- a/config/util/representation-conversion/converters/markdown.json +++ b/config/util/representation-conversion/converters/markdown.json @@ -14,6 +14,16 @@ "@type": "HandlebarsTemplateEngine", "template": "$PACKAGE_ROOT/templates/main.html.hbs" } + }, + { + "comment": "Converts a container into a Markdown listing of its contents.", + "@id": "urn:solid-server:default:ContainerToTemplateConverter", + "@type": "ContainerToTemplateConverter", + "templateEngine": { + "@type": "HandlebarsTemplateEngine", + "template": "$PACKAGE_ROOT/templates/container.md.hbs" + }, + "contentType": "text/markdown" } ] } diff --git a/config/util/representation-conversion/default.json b/config/util/representation-conversion/default.json index 6eea4bb95..4c48f8c13 100644 --- a/config/util/representation-conversion/default.json +++ b/config/util/representation-conversion/default.json @@ -2,9 +2,10 @@ "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^1.0.0/components/context.jsonld", "import": [ "files-scs:config/util/representation-conversion/converters/content-type-replacer.json", + "files-scs:config/util/representation-conversion/converters/errors.json", + "files-scs:config/util/representation-conversion/converters/markdown.json", "files-scs:config/util/representation-conversion/converters/quad-to-rdf.json", - "files-scs:config/util/representation-conversion/converters/rdf-to-quad.json", - "files-scs:config/util/representation-conversion/converters/markdown.json" + "files-scs:config/util/representation-conversion/converters/rdf-to-quad.json" ], "@graph": [ { @@ -26,18 +27,9 @@ "converters": [ { "@id": "urn:solid-server:default:RdfToQuadConverter" }, { "@id": "urn:solid-server:default:QuadToRdfConverter" }, - { "@type": "ErrorToQuadConverter" }, - { - "comment": "Converts an error into a Markdown description of its details.", - "@type": "ErrorToTemplateConverter", - "templateEngine": { - "@type": "HandlebarsTemplateEngine", - "template": "$PACKAGE_ROOT/templates/error/main.md.hbs" - }, - "templatePath": "$PACKAGE_ROOT/templates/error/descriptions/", - "extension": ".md.hbs", - "contentType": "text/markdown" - }, + { "@id": "urn:solid-server:default:ContainerToTemplateConverter" }, + { "@id": "urn:solid-server:default:ErrorToQuadConverter" }, + { "@id": "urn:solid-server:default:ErrorToTemplateConverter" }, { "@id": "urn:solid-server:default:MarkdownToHtmlConverter" } ] } diff --git a/package-lock.json b/package-lock.json index f4a3d50fa..aa1ad437b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,6 +18,7 @@ "@types/bcrypt": "^5.0.0", "@types/cors": "^2.8.10", "@types/end-of-stream": "^1.4.0", + "@types/lodash.orderby": "^4.6.6", "@types/marked": "^2.0.3", "@types/mime-types": "^2.1.0", "@types/n3": "^1.10.0", @@ -47,6 +48,7 @@ "fetch-sparql-endpoint": "^2.0.1", "handlebars": "^4.7.7", "jose": "^3.11.6", + "lodash.orderby": "^4.6.0", "marked": "^2.1.3", "mime-types": "^2.1.31", "n3": "^1.10.0", @@ -6004,8 +6006,7 @@ "node_modules/@types/lodash": { "version": "4.14.170", "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.170.tgz", - "integrity": "sha512-bpcvu/MKHHeYX+qeEN8GE7DIravODWdACVA1ctevD8CN24RhPZIKMn9ntfAsrvLfSX3cR5RrBKAbYm9bGs0A+Q==", - "dev": true + "integrity": "sha512-bpcvu/MKHHeYX+qeEN8GE7DIravODWdACVA1ctevD8CN24RhPZIKMn9ntfAsrvLfSX3cR5RrBKAbYm9bGs0A+Q==" }, "node_modules/@types/lodash.clonedeep": { "version": "4.5.6", @@ -6016,6 +6017,14 @@ "@types/lodash": "*" } }, + "node_modules/@types/lodash.orderby": { + "version": "4.6.6", + "resolved": "https://registry.npmjs.org/@types/lodash.orderby/-/lodash.orderby-4.6.6.tgz", + "integrity": "sha512-wQzu6xK+bSwhu45OeMI7fjywiIZiiaBzJB8W3fwnF1SJXHoOXRLutrSnVmq4yHPOM036qsy8lx9wHQcAbXNjJw==", + "dependencies": { + "@types/lodash": "*" + } + }, "node_modules/@types/lru-cache": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/@types/lru-cache/-/lru-cache-5.1.0.tgz", @@ -14304,6 +14313,11 @@ "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", "dev": true }, + "node_modules/lodash.orderby": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/lodash.orderby/-/lodash.orderby-4.6.0.tgz", + "integrity": "sha1-5pfwTOXXhSL1TZM4syuBozk+TrM=" + }, "node_modules/lodash.template": { "version": "4.5.0", "resolved": "https://registry.npmjs.org/lodash.template/-/lodash.template-4.5.0.tgz", @@ -23673,8 +23687,7 @@ "@types/lodash": { "version": "4.14.170", "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.170.tgz", - "integrity": "sha512-bpcvu/MKHHeYX+qeEN8GE7DIravODWdACVA1ctevD8CN24RhPZIKMn9ntfAsrvLfSX3cR5RrBKAbYm9bGs0A+Q==", - "dev": true + "integrity": "sha512-bpcvu/MKHHeYX+qeEN8GE7DIravODWdACVA1ctevD8CN24RhPZIKMn9ntfAsrvLfSX3cR5RrBKAbYm9bGs0A+Q==" }, "@types/lodash.clonedeep": { "version": "4.5.6", @@ -23685,6 +23698,14 @@ "@types/lodash": "*" } }, + "@types/lodash.orderby": { + "version": "4.6.6", + "resolved": "https://registry.npmjs.org/@types/lodash.orderby/-/lodash.orderby-4.6.6.tgz", + "integrity": "sha512-wQzu6xK+bSwhu45OeMI7fjywiIZiiaBzJB8W3fwnF1SJXHoOXRLutrSnVmq4yHPOM036qsy8lx9wHQcAbXNjJw==", + "requires": { + "@types/lodash": "*" + } + }, "@types/lru-cache": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/@types/lru-cache/-/lru-cache-5.1.0.tgz", @@ -30166,6 +30187,11 @@ "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", "dev": true }, + "lodash.orderby": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/lodash.orderby/-/lodash.orderby-4.6.0.tgz", + "integrity": "sha1-5pfwTOXXhSL1TZM4syuBozk+TrM=" + }, "lodash.template": { "version": "4.5.0", "resolved": "https://registry.npmjs.org/lodash.template/-/lodash.template-4.5.0.tgz", diff --git a/package.json b/package.json index 4798a5ff1..cc069bde1 100644 --- a/package.json +++ b/package.json @@ -84,6 +84,7 @@ "@types/bcrypt": "^5.0.0", "@types/cors": "^2.8.10", "@types/end-of-stream": "^1.4.0", + "@types/lodash.orderby": "^4.6.6", "@types/marked": "^2.0.3", "@types/mime-types": "^2.1.0", "@types/n3": "^1.10.0", @@ -113,6 +114,7 @@ "fetch-sparql-endpoint": "^2.0.1", "handlebars": "^4.7.7", "jose": "^3.11.6", + "lodash.orderby": "^4.6.0", "marked": "^2.1.3", "mime-types": "^2.1.31", "n3": "^1.10.0", diff --git a/src/index.ts b/src/index.ts index 8e8dee8fa..26b6e8d98 100644 --- a/src/index.ts +++ b/src/index.ts @@ -216,6 +216,7 @@ export * from './storage/accessors/SparqlDataAccessor'; // Storage/Conversion export * from './storage/conversion/ChainedConverter'; export * from './storage/conversion/ConstantConverter'; +export * from './storage/conversion/ContainerToTemplateConverter'; export * from './storage/conversion/ContentTypeReplacer'; export * from './storage/conversion/ConversionUtil'; export * from './storage/conversion/ErrorToQuadConverter'; diff --git a/src/storage/LockingResourceStore.ts b/src/storage/LockingResourceStore.ts index dd7533bae..d10924bf3 100644 --- a/src/storage/LockingResourceStore.ts +++ b/src/storage/LockingResourceStore.ts @@ -1,6 +1,4 @@ import type { Readable } from 'stream'; -import { promisify } from 'util'; -import eos from 'end-of-stream'; import type { AuxiliaryIdentifierStrategy } from '../ldp/auxiliary/AuxiliaryIdentifierStrategy'; import type { Patch } from '../ldp/http/Patch'; import { BasicRepresentation } from '../ldp/representation/BasicRepresentation'; @@ -9,10 +7,10 @@ import type { RepresentationPreferences } from '../ldp/representation/Representa import type { ResourceIdentifier } from '../ldp/representation/ResourceIdentifier'; import { getLoggerFor } from '../logging/LogUtil'; import type { ExpiringReadWriteLocker } from '../util/locking/ExpiringReadWriteLocker'; +import { endOfStream } from '../util/StreamUtil'; import type { AtomicResourceStore } from './AtomicResourceStore'; import type { Conditions } from './Conditions'; import type { ResourceStore } from './ResourceStore'; -const endOfStream = promisify(eos); /** * Store that for every call acquires a lock before executing it on the requested resource, diff --git a/src/storage/conversion/ContainerToTemplateConverter.ts b/src/storage/conversion/ContainerToTemplateConverter.ts new file mode 100644 index 000000000..e142d2d9e --- /dev/null +++ b/src/storage/conversion/ContainerToTemplateConverter.ts @@ -0,0 +1,80 @@ +import type { Readable } from 'stream'; +import orderBy from 'lodash.orderby'; +import type { Quad } from 'rdf-js'; +import { BasicRepresentation } from '../../ldp/representation/BasicRepresentation'; +import type { Representation } from '../../ldp/representation/Representation'; +import { INTERNAL_QUADS } from '../../util/ContentTypes'; +import { NotImplementedHttpError } from '../../util/errors/NotImplementedHttpError'; +import { isContainerIdentifier, isContainerPath } from '../../util/PathUtil'; +import { endOfStream } from '../../util/StreamUtil'; +import type { TemplateEngine } from '../../util/templates/TemplateEngine'; +import { LDP } from '../../util/Vocabularies'; +import type { RepresentationConverterArgs } from './RepresentationConverter'; +import { TypedRepresentationConverter } from './TypedRepresentationConverter'; + +interface ResourceDetails { + name: string; + identifier: string; + container: boolean; +} + +/** + * A {@link RepresentationConverter} that creates a templated representation of a container. + */ +export class ContainerToTemplateConverter extends TypedRepresentationConverter { + private readonly templateEngine: TemplateEngine; + private readonly contentType: string; + + public constructor(templateEngine: TemplateEngine, contentType: string) { + super(INTERNAL_QUADS, contentType); + this.templateEngine = templateEngine; + this.contentType = contentType; + } + + public async canHandle(args: RepresentationConverterArgs): Promise { + if (!isContainerIdentifier(args.identifier)) { + throw new NotImplementedHttpError('Can only convert containers.'); + } + await super.canHandle(args); + } + + public async handle({ identifier, representation }: RepresentationConverterArgs): Promise { + const rendered = await this.templateEngine.render({ + container: this.getLocalName(identifier.path), + children: await this.getChildResources(identifier.path, representation.data), + }); + return new BasicRepresentation(rendered, representation.metadata, this.contentType); + } + + /** + * Collects the children of the container as simple objects. + */ + private async getChildResources(container: string, quads: Readable): Promise { + // Collect the needed bits of information from the containment triples + const resources = new Set(); + quads.on('data', ({ subject, predicate, object }: Quad): void => { + if (subject.value === container && predicate.equals(LDP.terms.contains)) { + resources.add(object.value); + } + }); + await endOfStream(quads); + + // Create a simplified object for every resource + const children = [ ...resources ].map((resource: string): ResourceDetails => ({ + identifier: resource, + name: this.getLocalName(resource), + container: isContainerPath(resource), + })); + + // Sort the resulting list + return orderBy(children, [ 'container', 'identifier' ], [ 'desc', 'asc' ]); + } + + /** + * Derives a short name for the given resource. + */ + private getLocalName(iri: string, keepTrailingSlash = false): string { + const match = /:\/+[^/]+.*\/(([^/]+)\/?)$/u.exec(iri); + return match ? decodeURIComponent(match[keepTrailingSlash ? 1 : 2]) : '/'; + } +} diff --git a/src/util/StreamUtil.ts b/src/util/StreamUtil.ts index a0a18030e..6c59d2770 100644 --- a/src/util/StreamUtil.ts +++ b/src/util/StreamUtil.ts @@ -1,12 +1,16 @@ import type { Writable, ReadableOptions, DuplexOptions } from 'stream'; import { Readable, Transform } from 'stream'; +import { promisify } from 'util'; import arrayifyStream from 'arrayify-stream'; +import eos from 'end-of-stream'; import pump from 'pump'; import { getLoggerFor } from '../logging/LogUtil'; import { isHttpRequest } from '../server/HttpRequest'; import type { Guarded } from './GuardedStream'; import { guardStream } from './GuardedStream'; +export const endOfStream = promisify(eos); + const logger = getLoggerFor('StreamUtil'); /** diff --git a/templates/config/defaults.json b/templates/config/defaults.json index 0206c1f0f..99be4ed29 100644 --- a/templates/config/defaults.json +++ b/templates/config/defaults.json @@ -11,7 +11,8 @@ }, { - "@id": "urn:solid-server:template:IdentifierStrategy", + "comment": "Custom pods always use the suffix strategy with their pod URL as base.", + "@id": "urn:solid-server:default:IdentifierStrategy", "@type": "SingleRootIdentifierStrategy", "baseUrl": { "@id": "urn:solid-server:template:variable:baseUrl" @@ -25,7 +26,7 @@ "@id": "urn:solid-server:template:DataAccessor" }, "identifierStrategy": { - "@id": "urn:solid-server:template:IdentifierStrategy" + "@id": "urn:solid-server:default:IdentifierStrategy" }, "auxiliaryStrategy": { "@id": "urn:solid-server:default:AuxiliaryStrategy" diff --git a/templates/config/memory.json b/templates/config/memory.json index 8e0d4c477..dfd1ce9cd 100644 --- a/templates/config/memory.json +++ b/templates/config/memory.json @@ -12,7 +12,7 @@ "@id": "urn:solid-server:template:DataAccessor", "@type": "InMemoryDataAccessor", "identifierStrategy": { - "@id": "urn:solid-server:template:IdentifierStrategy" + "@id": "urn:solid-server:default:IdentifierStrategy" } } ] diff --git a/templates/container.md.hbs b/templates/container.md.hbs new file mode 100644 index 000000000..07b0c6804 --- /dev/null +++ b/templates/container.md.hbs @@ -0,0 +1,8 @@ +# Contents of {{container}} +
    +{{#each children}} +
  • + {{name}} +
  • +{{/each}} +
diff --git a/templates/styles/main.css b/templates/styles/main.css index 1f74f62bf..4af1c3722 100644 --- a/templates/styles/main.css +++ b/templates/styles/main.css @@ -174,3 +174,12 @@ form ul.actions > li { display: inline; margin-right: 1em; } + +ul.container > li { + margin: 0.25em 0; + list-style-type: none; +} + +ul.container > li.container > a { + font-weight: 800; +} diff --git a/test/unit/storage/conversion/ContainerToTemplateConverter.test.ts b/test/unit/storage/conversion/ContainerToTemplateConverter.test.ts new file mode 100644 index 000000000..acda1ed93 --- /dev/null +++ b/test/unit/storage/conversion/ContainerToTemplateConverter.test.ts @@ -0,0 +1,95 @@ +import { namedNode as nn, quad } from '@rdfjs/data-model'; +import { BasicRepresentation } from '../../../../src/ldp/representation/BasicRepresentation'; +import { ContainerToTemplateConverter } from '../../../../src/storage/conversion/ContainerToTemplateConverter'; +import { readableToString } from '../../../../src/util/StreamUtil'; +import type { TemplateEngine } from '../../../../src/util/templates/TemplateEngine'; +import { LDP, RDF } from '../../../../src/util/Vocabularies'; + +describe('A ContainerToTemplateConverter', (): void => { + const preferences = {}; + let templateEngine: jest.Mocked; + let converter: ContainerToTemplateConverter; + + beforeEach(async(): Promise => { + templateEngine = { + render: jest.fn().mockReturnValue(Promise.resolve('')), + }; + converter = new ContainerToTemplateConverter(templateEngine, 'text/html'); + }); + + it('supports containers.', async(): Promise => { + const container = { path: 'http://test.com/foo/bar/container/' }; + const representation = new BasicRepresentation([], 'internal/quads', false); + await expect(converter.canHandle({ identifier: container, representation, preferences })) + .resolves.toBeUndefined(); + }); + + it('does not support documents.', async(): Promise => { + const document = { path: 'http://test.com/foo/bar/document' }; + const representation = new BasicRepresentation([], 'internal/quads', false); + await expect(converter.canHandle({ identifier: document, representation, preferences })) + .rejects.toThrow('Can only convert containers'); + }); + + it('calls the template with the contained resources.', async(): Promise => { + const container = { path: 'http://test.com/foo/bar/my-container/' }; + const representation = new BasicRepresentation([ + quad(nn(container.path), RDF.terms.type, LDP.terms.BasicContainer), + quad(nn(container.path), LDP.terms.contains, nn(`${container.path}b`)), + quad(nn(container.path), LDP.terms.contains, nn(`${container.path}a`)), + quad(nn(container.path), LDP.terms.contains, nn(`${container.path}a`)), + quad(nn(container.path), LDP.terms.contains, nn(`${container.path}ccc`)), + quad(nn(container.path), LDP.terms.contains, nn(`${container.path}d/`)), + quad(nn(`${container.path}d/`), LDP.terms.contains, nn(`${container.path}d`)), + ], 'internal/quads', false); + const converted = await converter.handle({ identifier: container, representation, preferences }); + + expect(converted.binary).toBe(true); + expect(converted.metadata.contentType).toBe('text/html'); + await expect(readableToString(converted.data)).resolves.toBe(''); + + expect(templateEngine.render).toHaveBeenCalledTimes(1); + expect(templateEngine.render).toHaveBeenCalledWith({ + container: 'my-container', + children: [ + { + identifier: `${container.path}d/`, + name: 'd/', + container: true, + }, + { + identifier: `${container.path}a`, + name: 'a', + container: false, + }, + { + identifier: `${container.path}b`, + name: 'b', + container: false, + }, + { + identifier: `${container.path}ccc`, + name: 'ccc', + container: false, + }, + ], + }); + }); + + it('converts the root container.', async(): Promise => { + const container = { path: 'http://test.com/' }; + const representation = new BasicRepresentation([ + quad(nn(container.path), RDF.terms.type, LDP.terms.BasicContainer), + quad(nn(container.path), LDP.terms.contains, nn(`${container.path}a`)), + quad(nn(container.path), LDP.terms.contains, nn(`${container.path}b`)), + quad(nn(container.path), LDP.terms.contains, nn(`${container.path}c`)), + ], 'internal/quads', false); + await converter.handle({ identifier: container, representation, preferences }); + + expect(templateEngine.render).toHaveBeenCalledTimes(1); + expect(templateEngine.render).toHaveBeenCalledWith({ + container: '/', + children: expect.objectContaining({ length: 3 }), + }); + }); +});