diff --git a/package-lock.json b/package-lock.json index 363ba6e..0c108fe 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2616,9 +2616,9 @@ } }, "node_modules/@iconify-json/lucide": { - "version": "1.2.122", - "resolved": "https://registry.npmjs.org/@iconify-json/lucide/-/lucide-1.2.122.tgz", - "integrity": "sha512-Dpt+ZbYTyKZqyRkEHmxEcsSsRW9yRTmkaweT2A1hfH7zKHZZ7FKmcn5evTh93ZlkYq2Vpb1YZUBsjc9AxZlcUg==", + "version": "1.2.123", + "resolved": "https://registry.npmjs.org/@iconify-json/lucide/-/lucide-1.2.123.tgz", + "integrity": "sha512-0CozmpKXEOEEhltrfT2zt+/t1hez67Y+hM2VJWoIcBKdBrb83VYuKf+b5A0QU9HfQm+RNn2GjFWlTOwi+Ss6IA==", "dev": true, "license": "ISC", "dependencies": { @@ -3384,15 +3384,15 @@ "license": "MIT" }, "node_modules/@typescript-eslint/parser": { - "version": "8.66.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.66.0.tgz", - "integrity": "sha512-X6ypGChaWYk6PBtUg2BwuTZEFFcHJAtGTVJ9/lCTOufhZ4i9fNolQNnktq+kkMCwMj7V8Svsq7+TxSDslmhE0g==", + "version": "8.67.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.67.0.tgz", + "integrity": "sha512-fUBfTuuEulWqX6V8+O3PtScV01tzYYRUDTAirHFKoRAt7nOzoGiPt0M/bB47wWNy0coOOcgEwAMUtBpykMxl6w==", "license": "MIT", "dependencies": { - "@typescript-eslint/scope-manager": "8.66.0", - "@typescript-eslint/types": "8.66.0", - "@typescript-eslint/typescript-estree": "8.66.0", - "@typescript-eslint/visitor-keys": "8.66.0", + "@typescript-eslint/scope-manager": "8.67.0", + "@typescript-eslint/types": "8.67.0", + "@typescript-eslint/typescript-estree": "8.67.0", + "@typescript-eslint/visitor-keys": "8.67.0", "debug": "^4.4.3" }, "engines": { @@ -3408,13 +3408,13 @@ } }, "node_modules/@typescript-eslint/project-service": { - "version": "8.66.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.66.0.tgz", - "integrity": "sha512-7MthGPTt4BP69lSryqpqq8HQqxuzynssckL/jyDyk3+TNMQ3y2jFWkptCrktWvBrP+EH787Nl5N5Qpw7WZg+5g==", + "version": "8.67.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.67.0.tgz", + "integrity": "sha512-cvE8c7ulYeXN9fYuszhCeCsbzyVEXuhrRCybnBre7TUmqb5nRmBfQAwCj0O3WJFDeyAZt4VYv51vMCC9LHSdYw==", "license": "MIT", "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.66.0", - "@typescript-eslint/types": "^8.66.0", + "@typescript-eslint/tsconfig-utils": "^8.67.0", + "@typescript-eslint/types": "^8.67.0", "debug": "^4.4.3" }, "engines": { @@ -3429,13 +3429,13 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.66.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.66.0.tgz", - "integrity": "sha512-8TGcH25j9zqJ/IULB/ppyhRvxA8QYfFEZ7nfbg6/BN9spDgb8fPWQXlE5l8TWBL50EtUx007uZ1o9VOwrq2/9g==", + "version": "8.67.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.67.0.tgz", + "integrity": "sha512-EgvsleTwS4E+WzzSvem8fAUubLwatMNF1B5hHSLQxcvs7q2dtRhGyujHwLJSYlG41niJ7GP24Aha2+0mb1b2kg==", "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.66.0", - "@typescript-eslint/visitor-keys": "8.66.0" + "@typescript-eslint/types": "8.67.0", + "@typescript-eslint/visitor-keys": "8.67.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -3446,9 +3446,9 @@ } }, "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.66.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.66.0.tgz", - "integrity": "sha512-9D5gLYZG4rOjcoag8MQ/fWI8WqA9wcPDyOGyWtWFhvM1lHRbliqUSPIY5J3zqCU1tvSwzXxnnjhQhz5Ne7mJ4g==", + "version": "8.67.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.67.0.tgz", + "integrity": "sha512-vV+LUSv5njUWsknE71fqKTlXUva+R76SaeORd6Zojcunk/6DvKFXONU3BrAs2H49mbygUXt6gbYunzwqNwlhdg==", "license": "MIT", "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -3462,9 +3462,9 @@ } }, "node_modules/@typescript-eslint/types": { - "version": "8.66.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.66.0.tgz", - "integrity": "sha512-H6gcYaSDOyvL3AD/jHUtUFo2jqGgn/F6nuyuZSu0QTesxL+cP4dQoIMrODRofuJC09g64+WgZ6tE19Y1N2YIFQ==", + "version": "8.67.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.67.0.tgz", + "integrity": "sha512-sBtgslww8nsMYUjhdPBiSyUqSzT8uR6g93A2QXnQC8+cGdjz0CyaOdqHDRJb1AtORbZCNUJBBeFA/tNR2uQmww==", "license": "MIT", "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -3475,15 +3475,15 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.66.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.66.0.tgz", - "integrity": "sha512-8/x4INiiQb10jGgXYD7116/zQ+OL84ZIFn0za68wwFHCanT/VLbBEroWht8RV8fn0/ZCAoazHLQgwUC0UQcDfg==", + "version": "8.67.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.67.0.tgz", + "integrity": "sha512-EKQBCE9yNlRJYm7jdTW5AhDacDUmSwQb0FAJAmK2EKYrNXIsa2vxcSZx6PvJ/dEdI6lS+Y9W+EXckLj0iPFGcw==", "license": "MIT", "dependencies": { - "@typescript-eslint/project-service": "8.66.0", - "@typescript-eslint/tsconfig-utils": "8.66.0", - "@typescript-eslint/types": "8.66.0", - "@typescript-eslint/visitor-keys": "8.66.0", + "@typescript-eslint/project-service": "8.67.0", + "@typescript-eslint/tsconfig-utils": "8.67.0", + "@typescript-eslint/types": "8.67.0", + "@typescript-eslint/visitor-keys": "8.67.0", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", @@ -3502,12 +3502,12 @@ } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.66.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.66.0.tgz", - "integrity": "sha512-dkKR8q+lKciskj1Y3vthHktl+3cMLWGyVUP23bRiPZ5O9BRT++4EqDDV+TVeIKBL1VXVEqrJlz8MYbcnvJcAlg==", + "version": "8.67.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.67.0.tgz", + "integrity": "sha512-fkv8dHRDqfGtTHuJeebdrQ7cX6Ad4WAS00rgHh9UGvMycF1mjBfsxry1XsLIFhWZ6Judlh6UdzK+TYlbpCXgnA==", "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.66.0", + "@typescript-eslint/types": "8.67.0", "eslint-visitor-keys": "^5.0.0" }, "engines": { @@ -9503,9 +9503,9 @@ } }, "node_modules/update-browserslist-db": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.0.tgz", - "integrity": "sha512-x/M6q3w4Ybp91CNaS4S69UnliqR3BzRpOT6LWbksjth0S/+jhfaPJsWjt/TewpT8j9eLIojUf5jr29WextHroA==", + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.1.tgz", + "integrity": "sha512-ZZ61DsRsOnakl74HAmp3oSN4aXUmEWXf+i/yv0h7tIBfICc3VdrFErQKUUKPgu3AMsTUMbcongALEN4l6GSUrQ==", "dev": true, "funding": [ { diff --git a/src/components/source-editor-card/SourceEditorCard.ts b/src/components/source-editor-card/SourceEditorCard.ts index 1b3d683..6b40fc1 100644 --- a/src/components/source-editor-card/SourceEditorCard.ts +++ b/src/components/source-editor-card/SourceEditorCard.ts @@ -10,7 +10,7 @@ import styles from './SourceEditorCard.styles.css' import { getStatusSection } from '../../StatusSection' import { compactable } from '../../compactableFormats' import { sourceContext, SourceContext } from '../../primitives/context' -import { getResponseMetadata } from '../../resourceLoader' +import { fetchMetadata } from '../source-provider/resourceLoading' @customElement('source-pane-source-editor-card') export default class SourceEditorCard extends WebComponent { @@ -191,10 +191,11 @@ export default class SourceEditorCard extends WebComponent { this._originalContent = data /// @@ show edited: make save button disabled until edited again. try { - const response = await fetcher.webOperation('HEAD', saveSubject.uri) // , defaultFetchHeaders()) - if (!happy(response, 'HEAD')) return - const metadata = getResponseMetadata(store, saveSubject as any, response) - sourceContext.updateMetadata(metadata) + const metadata = await fetchMetadata(store, saveSubject) + sourceContext.updateMetadata({ + contentType: metadata.contentType, + eTag: metadata.eTag + }) this._resetEditorState() } catch (err) { throw err diff --git a/src/components/source-provider/SourceProvider.ts b/src/components/source-provider/SourceProvider.ts index f4cf1e3..47e0476 100644 --- a/src/components/source-provider/SourceProvider.ts +++ b/src/components/source-provider/SourceProvider.ts @@ -9,9 +9,9 @@ import { WebComponent } from 'solid-ui' import { sourceContext, SourceContext } from '../../primitives/context' import { SourcePaneState, EditorMetadata } from '../../types' import type SourceEditorCard from '../source-editor-card/SourceEditorCard' -import { fetchContentAndMetadata } from '../../resourceLoader' import { fileExplorerContext, type FileExplorerContext } from 'solid-ui' import styles from './SourceProvider.styles.css' +import { fetchContentAndMetadata } from './resourceLoading' void import('../source-editor-card/SourceEditorCard').then(() => undefined) function createDefaultSourcePaneState(): SourcePaneState { @@ -111,9 +111,16 @@ export default class SourceProvider extends WebComponent { throw new Error('The element is missing the required `subject` property.') } - const { content, metadata } = await fetchContentAndMetadata(this.context.session.store as any, this.subject) + const store = (this.fileExplorerContextValue.store ?? this.context?.session.store) as DataBrowserContext['session']['store'] + const { content, metadata } = await fetchContentAndMetadata(store, this.subject) + if (!metadata.contentType) { + throw new Error('Error: No content-type available!') + } this.originalContent = content - this.updateEditorMetadata(metadata) + this.updateEditorMetadata({ + contentType: metadata.contentType, + eTag: metadata.eTag + }) this.dataLoaded = true } catch (error: any) { const { showError } = getStatusSection() diff --git a/src/components/source-provider/resourceLoading.ts b/src/components/source-provider/resourceLoading.ts new file mode 100644 index 0000000..3de33cb --- /dev/null +++ b/src/components/source-provider/resourceLoading.ts @@ -0,0 +1,48 @@ +import type { LiveStore, NamedNode } from 'rdflib' + +type ResourceMetadata = { + contentType: string | undefined + eTag: string | undefined +} + +function assertSuccessfulHttpResponse(response: Response, method: string) { + if (response.ok) return + + const message = response.status === 412 + ? 'Error: File changed by someone else' + : `HTTP error on ${method}! Status: ${response.status}` + throw new Error(message) +} + +function readContentType(response: Response) { + const contentTypeHeader = response.headers?.get('content-type') + return contentTypeHeader?.split(';')[0] ?? undefined +} + +export async function fetchContentAndMetadata(store: LiveStore, subject: NamedNode): Promise<{ content: string, metadata: ResourceMetadata }> { + const response = await store.fetcher.webOperation('GET', subject.uri) + assertSuccessfulHttpResponse(response, 'GET') + + const content = (response as Response & { responseText?: string }).responseText + if (content === undefined) { + throw new Error('No text in response object!!') + } + + return { + content, + metadata: { + contentType: readContentType(response), + eTag: response.headers?.get('etag') ?? undefined + } + } +} + +export async function fetchMetadata(store: LiveStore, subject: NamedNode): Promise { + const response = await store.fetcher.webOperation('HEAD', subject.uri) + assertSuccessfulHttpResponse(response, 'HEAD') + + return { + contentType: readContentType(response), + eTag: response.headers?.get('etag') ?? undefined + } +} \ No newline at end of file diff --git a/src/resourceLoader.ts b/src/resourceLoader.ts deleted file mode 100644 index 2a83d2b..0000000 --- a/src/resourceLoader.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { ns } from 'solid-ui' -import { log } from './debug' -import { EditorMetadata } from './types' -import { LiveStore, NamedNode } from 'rdflib' -import { happy } from './helpers' - -export function getResponseMetadata (store: LiveStore, subject: NamedNode, response: Response): EditorMetadata { - let contentType: string | undefined - let eTag: string | undefined - - if (response.headers && response.headers.get('content-type')) { - contentType = response.headers.get('content-type')?.split(';')[0] ?? undefined // Should work but headers may be empty - eTag = response.headers.get('etag') ?? undefined - } else { - const reqs = store.each( - null, - store.sym('http://www.w3.org/2007/ont/link#requestedURI'), - subject - ) - reqs.forEach((req: any) => { - const responseNode = store.any( - req as any, - store.sym('http://www.w3.org/2007/ont/link#response') - ) - if (responseNode && responseNode.termType === 'NamedNode') { - contentType = store.anyValue(responseNode as any, ns.httph('content-type')) || undefined - eTag = store.anyValue(responseNode as any, ns.httph('etag')) || undefined - if (!eTag) log('sourcePane: No eTag on GET') - } - }) - } - - return { contentType, eTag } as EditorMetadata -} - -export async function fetchContentAndMetadata(store: LiveStore, subject: NamedNode): Promise<{ content: string, metadata: EditorMetadata }> { - const fetcher = store.fetcher - - try { - const response = await fetcher.webOperation('GET', subject.uri) - if (!happy(response, 'GET')) { - throw new Error('GET request failed') - } - - const content = (response as Response & { responseText?: string }).responseText - if (content === undefined) { // Defensive https://github.com/linkeddata/rdflib.js/issues/506 - throw new Error('source pane: No text in response object!!') - } - - const metadata = getResponseMetadata(store, subject, response) - if (!metadata.contentType) { - throw new Error('Error: No content-type available!') - } - return { content, metadata } - } catch (error: any) { - throw new Error(`Error reading file: ${error.message}`) - } - -} diff --git a/test/helpers.test.js b/test/helpers.test.js deleted file mode 100644 index 1273e4c..0000000 --- a/test/helpers.test.js +++ /dev/null @@ -1,69 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' - -import { fetchContentAndMetadata, getResponseMetadata } from '../src/resourceLoader.ts' - -describe('resourceLoader', () => { - beforeEach(() => { - vi.clearAllMocks() - }) - - it('reads response headers into metadata', () => { - const response = { - headers: { - get: vi.fn((name) => { - if (name === 'content-type') return 'text/turtle; charset=utf-8' - if (name === 'wac-allow') return 'user="write", public="read"' - if (name === 'etag') return '"abc"' - return null - }) - } - } - const store = { - each: vi.fn(), - any: vi.fn(), - anyValue: vi.fn(() => undefined), - sym: vi.fn() - } - const subject = { uri: 'https://example.org/profile/card' } - - expect(getResponseMetadata(store, subject, response)).toEqual({ - contentType: 'text/turtle', - eTag: '"abc"' - }) - }) - - it('fetches content and applies the returned metadata', async () => { - const response = { - ok: true, - headers: { - get: vi.fn((name) => { - if (name === 'content-type') return 'text/turtle' - if (name === 'wac-allow') return 'user="write", public="read"' - if (name === 'etag') return '"abc"' - return null - }) - }, - responseText: '<> a <#Thing>.' - } - const store = { - fetcher: { - webOperation: vi.fn().mockResolvedValue(response) - }, - each: vi.fn(), - any: vi.fn(), - anyValue: vi.fn(() => undefined), - sym: vi.fn() - } - const subject = { uri: 'https://example.org/profile/card' } - - const result = await fetchContentAndMetadata(store, subject) - - expect(result).toEqual({ - content: '<> a <#Thing>.', - metadata: { - contentType: 'text/turtle', - eTag: '"abc"' - } - }) - }) -}) diff --git a/test/helpers/resourceLoading.test.js b/test/helpers/resourceLoading.test.js new file mode 100644 index 0000000..e22c402 --- /dev/null +++ b/test/helpers/resourceLoading.test.js @@ -0,0 +1,44 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { fetchContentAndMetadata, fetchMetadata } from '../../src/components/source-provider/resourceLoading' +import { mockWebOperationOnceIf, subject, context } from './setup' + +describe('resourceLoading', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('loads content and metadata from the provided store', async () => { + mockWebOperationOnceIf(subject.uri, { + body: 'hello world', + headers: { + 'content-type': 'text/turtle; charset=utf-8', + etag: '"abc"' + } + }) + + const result = await fetchContentAndMetadata(context.session.store, subject) + + expect(result.content).toBe('hello world') + expect(result.metadata).toEqual({ + contentType: 'text/turtle', + eTag: '"abc"' + }) + }) + + it('loads metadata from the provided store', async () => { + mockWebOperationOnceIf(subject.uri, { + body: '', + headers: { + 'content-type': 'text/plain', + etag: '"etag-1"' + } + }) + + const result = await fetchMetadata(context.session.store, subject) + + expect(result).toEqual({ + contentType: 'text/plain', + eTag: '"etag-1"' + }) + }) +}) \ No newline at end of file