diff --git a/package-lock.json b/package-lock.json index 65a0ab7..28fb516 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "solid-logic", - "version": "6.0.1-4", + "version": "6.0.1-5", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "solid-logic", - "version": "6.0.1-4", + "version": "6.0.1-5", "license": "MIT", "dependencies": { "@dokieli/web-access-control": "^1.1.0", diff --git a/package.json b/package.json index b80fb5f..9a1e3b7 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "solid-logic", - "version": "6.0.1-4", + "version": "6.0.1-5", "description": "Core business logic of SolidOS", "main": "dist/index.cjs.js", "module": "dist/index.esm.js", diff --git a/src/resource/resourceLogic.ts b/src/resource/resourceLogic.ts index a090a4f..5df5d0f 100644 --- a/src/resource/resourceLogic.ts +++ b/src/resource/resourceLogic.ts @@ -1,4 +1,4 @@ -import { NamedNode, sym } from 'rdflib' +import { NamedNode, sym, Util } from 'rdflib' import { ACL_LINK } from '../acl/aclLogic' import { ns } from '../util/ns' import { assertSuccessfulHttpResponse, isMissingError } from './resourceHttp' @@ -55,6 +55,42 @@ export function createResourceLogic(store, aclLogic: AclLogic, containerLogic, t return pathEnd.startsWith('.') || pathEnd.endsWith('.acl') || pathEnd.endsWith('.meta') || pathEnd.endsWith('~') } + function isMediaType(resourceNode: NamedNode, mediaType: string) { + const typeURIs = store.findTypeURIs(resourceNode as any) + const normalizedMediaType = mediaType.toLowerCase() + const mediaTypePrefix = Util.mediaTypeClass(`${normalizedMediaType}/*`).uri.split('*')[0] + + if (Object.keys(typeURIs).some(typeURI => typeURI.startsWith(mediaTypePrefix))) { + return true + } + + const mediaTypeClasses: Record = { + audio: 'http://purl.org/dc/dcmitype/Sound', + image: 'http://purl.org/dc/dcmitype/Image', + video: 'http://purl.org/dc/dcmitype/MovingImage' + } + if (mediaTypeClasses[normalizedMediaType] in typeURIs) { + return true + } + + const contentTypes = store.fetcher.getHeader(resourceNode, 'content-type') || [] + return contentTypes.some((contentType: string) => + contentType.split(';', 1)[0].trim().toLowerCase().startsWith(`${normalizedMediaType}/`) + ) + } + + function isImage(resourceNode: NamedNode) { + return isMediaType(resourceNode, 'image') + } + + function isAudio(resourceNode: NamedNode) { + return isMediaType(resourceNode, 'audio') + } + + function isVideo(resourceNode: NamedNode) { + return isMediaType(resourceNode, 'video') + } + function isWebId(resource: string | NamedNode) { const uri = typeof resource === 'string' ? resource : resource.value @@ -259,6 +295,10 @@ export function createResourceLogic(store, aclLogic: AclLogic, containerLogic, t containerHasIndexDocument, isStorageRoot, isHiddenFile, + isMediaType, + isImage, + isAudio, + isVideo, isWebId } } diff --git a/src/types.ts b/src/types.ts index 0d118b6..56c1cfe 100644 --- a/src/types.ts +++ b/src/types.ts @@ -173,6 +173,10 @@ export interface ResourceLogic { containerHasIndexDocument: (container: NamedNode) => boolean, isStorageRoot: (resource: NamedNode) => boolean, isHiddenFile: (resource: NamedNode) => boolean, + isMediaType: (resource: NamedNode, mediaType: string) => boolean, + isImage: (resource: NamedNode) => boolean, + isAudio: (resource: NamedNode) => boolean, + isVideo: (resource: NamedNode) => boolean, isWebId: (resource: string | NamedNode) => boolean } diff --git a/test/resource/resourceLogic.test.ts b/test/resource/resourceLogic.test.ts index 6673b4a..5bcb673 100644 --- a/test/resource/resourceLogic.test.ts +++ b/test/resource/resourceLogic.test.ts @@ -17,6 +17,7 @@ function buildResourceLogic(overrides: { fetcher: { webOperation: vi.fn(overrides.webOperation ?? (async () => { throw new Error('unexpected webOperation') })), _fetch: vi.fn(async (..._args: any[]) => ({ ok: true })), + getHeader: vi.fn().mockReturnValue([]), load: vi.fn(), unload: vi.fn() }, @@ -28,6 +29,7 @@ function buildResourceLogic(overrides: { any: vi.fn().mockReturnValue(undefined), holds: vi.fn().mockReturnValue(false), each: vi.fn().mockReturnValue([]), + findTypeURIs: vi.fn().mockReturnValue({}), removeMatches: vi.fn(), removeDocument: vi.fn(), sym @@ -144,6 +146,35 @@ describe('createResourceLogic', () => { expect(resourceLogic.isHiddenFile(sym('https://example.com/workspace/file.ttl'))).toBe(false) }) + it('recognizes media from RDF types and cached content-type headers without fetching', () => { + const { resourceLogic, store } = buildResourceLogic() + const image = sym('https://example.com/workspace/image.png') + const audio = sym('https://example.com/workspace/audio.mp3') + const video = sym('https://example.com/workspace/video.mp4') + + store.fetcher.getHeader.mockImplementation((resource: any) => { + if (resource.uri === image.uri) return ['image/png; charset=binary'] + if (resource.uri === audio.uri) return ['audio/mpeg'] + return [] + }) + + expect(resourceLogic.isImage(image)).toBe(true) + expect(resourceLogic.isAudio(audio)).toBe(true) + expect(resourceLogic.isVideo(video)).toBe(false) + expect(store.fetcher.webOperation).not.toHaveBeenCalled() + }) + + it('recognizes RDF media types when no content-type header is cached', () => { + const { resourceLogic, store } = buildResourceLogic() + const image = sym('https://example.com/workspace/image') + store.findTypeURIs.mockReturnValue({ + 'http://purl.org/dc/dcmitype/Image': true + }) + + expect(resourceLogic.isImage(image)).toBe(true) + expect(store.fetcher.webOperation).not.toHaveBeenCalled() + }) + it('builds index resources consistently for containers with or without a trailing slash', () => { const { resourceLogic } = buildResourceLogic()