From c072d701f5c5c1c15466b92d989af25e472c13af Mon Sep 17 00:00:00 2001 From: Alan Lail Date: Tue, 1 Sep 2026 14:40:19 -0400 Subject: [PATCH] By default, importing a framework now mirrors it --- .../infrastructure/caseApi/CaseApiClient.ts | 4 +- .../ui/shared/components/FrameworkCard.tsx | 14 +-- .../case/endpoints/CreateFramework.ts | 9 +- .../case/endpoints/GetAllCFDocuments.ts | 6 +- .../case/endpoints/ImportFramework.ts | 26 ++++-- .../__tests__/ImportFramework.test.ts | 38 +++++++- .../src/domain/case/entities/CFAssociation.ts | 87 +++++++++++-------- .../src/domain/case/entities/CFDocument.ts | 40 +++++---- .../src/domain/case/entities/CFItem.ts | 85 ++++++++++-------- .../src/domain/case/entities/CFRubric.ts | 13 ++- .../file/FileCFPackageRepository.ts | 14 ++- .../__tests__/FileCFPackageRepository.test.ts | 54 ++++++++++++ 12 files changed, 272 insertions(+), 118 deletions(-) diff --git a/apps/editor/src/infrastructure/caseApi/CaseApiClient.ts b/apps/editor/src/infrastructure/caseApi/CaseApiClient.ts index f18a064..f22182b 100644 --- a/apps/editor/src/infrastructure/caseApi/CaseApiClient.ts +++ b/apps/editor/src/infrastructure/caseApi/CaseApiClient.ts @@ -28,9 +28,9 @@ export type CfDocumentSummary = { adoptionStatus?: string lastChangeDateTime?: string caseVersion?: string - /** URL the framework was imported from (set during import via backend). */ + /** URL the framework was imported from, if known (set during import via backend). */ sourcePackageURI?: string - /** True when an imported framework has been locally modified after import. */ + /** Set (true or false) once a framework has been imported/mirrored; true once it's been locally modified (forked). */ isModifiedFromSource?: boolean /** Server-level archive flag — independent of CASE adoptionStatus */ archived?: boolean diff --git a/apps/editor/src/ui/shared/components/FrameworkCard.tsx b/apps/editor/src/ui/shared/components/FrameworkCard.tsx index d57fc18..c2b0b24 100644 --- a/apps/editor/src/ui/shared/components/FrameworkCard.tsx +++ b/apps/editor/src/ui/shared/components/FrameworkCard.tsx @@ -70,9 +70,9 @@ type Props = { isUnsaved?: boolean /** ISO date string for the last change — shown in the card footer */ lastChanged?: string - /** URL the framework was imported from — shows an "Imported" badge when set */ + /** URL the framework was imported from, if known — shown in the Mirrored/Forked badge tooltip */ sourcePackageURI?: string - /** True when an imported framework has been locally modified */ + /** Set (true or false) once a framework has been imported/mirrored; true once it's been locally modified (forked) */ isModifiedFromSource?: boolean } @@ -218,7 +218,7 @@ export function FrameworkCard({ Unsaved ) : null} - {sourcePackageURI ? ( + {isModifiedFromSource !== undefined ? ( - {isModifiedFromSource ? 'Forked' : 'Imported'} + {isModifiedFromSource ? 'Forked' : 'Mirrored'} ) : null} diff --git a/apps/opencase/src/application/case/endpoints/CreateFramework.ts b/apps/opencase/src/application/case/endpoints/CreateFramework.ts index f9b53c3..f427ebf 100644 --- a/apps/opencase/src/application/case/endpoints/CreateFramework.ts +++ b/apps/opencase/src/application/case/endpoints/CreateFramework.ts @@ -135,8 +135,9 @@ export class CreateFramework { } } - // If the framework was previously imported (has sourcePackageURI in metadata), - // mark it as modified from source on subsequent saves from the editor. + // If the framework was previously imported as a mirror (has isModifiedFromSource + // in metadata, set at import time regardless of whether a source URL is known), + // mark it as modified from source — i.e. forked — on subsequent saves from the editor. let cfDocPayload = payload.CFDocument if (this.store) { const docId = (cfDocPayload.sourcedId ?? cfDocPayload.identifier) as string | undefined @@ -144,7 +145,7 @@ export class CreateFramework { // Check both CASE versions for existing metadata const existingMeta = this.store.getDocumentMetadata(tenantId, caseVersion, docId) ?? this.store.getDocumentMetadata(tenantId, caseVersion === '1.0' ? '1.1' : '1.0', docId) - if (existingMeta?.sourcePackageURI) { + if (existingMeta?.isModifiedFromSource !== undefined) { const existingExt = cfDocPayload.extensions ?? {} const existingOpencase = (existingExt['ext:opencase'] && typeof existingExt['ext:opencase'] === 'object') ? existingExt['ext:opencase'] @@ -155,7 +156,7 @@ export class CreateFramework { ...existingExt, 'ext:opencase': { ...existingOpencase, - sourcePackageURI: existingMeta.sourcePackageURI, + ...(existingMeta.sourcePackageURI ? { sourcePackageURI: existingMeta.sourcePackageURI } : {}), isModifiedFromSource: true, } } diff --git a/apps/opencase/src/application/case/endpoints/GetAllCFDocuments.ts b/apps/opencase/src/application/case/endpoints/GetAllCFDocuments.ts index 920227d..8ed00c7 100644 --- a/apps/opencase/src/application/case/endpoints/GetAllCFDocuments.ts +++ b/apps/opencase/src/application/case/endpoints/GetAllCFDocuments.ts @@ -125,7 +125,11 @@ export class GetAllCFDocuments { if (docMeta.version) doc.version = docMeta.version if (docMeta.adoptionStatus) doc.adoptionStatus = docMeta.adoptionStatus if (docMeta.sourcePackageURI) doc.sourcePackageURI = docMeta.sourcePackageURI - if (docMeta.isModifiedFromSource) doc.isModifiedFromSource = docMeta.isModifiedFromSource + // isModifiedFromSource is set (true or false) on any mirrored/forked framework, + // even one imported without a known sourcePackageURI (e.g. pasted JSON) — so it + // must be surfaced even when false, since the frontend uses its presence to + // decide whether to show the Mirrored/Forked badge at all. + if (docMeta.isModifiedFromSource !== undefined) doc.isModifiedFromSource = docMeta.isModifiedFromSource if (docMeta.archived) doc.archived = true // CASE v1.1-only fields: only include when not serving via v1p0 diff --git a/apps/opencase/src/application/case/endpoints/ImportFramework.ts b/apps/opencase/src/application/case/endpoints/ImportFramework.ts index 3935f61..05dd9e3 100644 --- a/apps/opencase/src/application/case/endpoints/ImportFramework.ts +++ b/apps/opencase/src/application/case/endpoints/ImportFramework.ts @@ -28,9 +28,10 @@ export interface ImportFrameworkResult { /** * Merge or create the `ext:opencase` extension on a CFDocument payload, - * setting sourcePackageURI and marking it as a pristine import. + * marking it as a pristine mirror of the source. `endpointUrl` is only known + * when the framework was imported by URL (as opposed to a pasted JSON payload). */ -function injectSourceProvenance (docPayload: any, endpointUrl: string): any { +function injectSourceProvenance (docPayload: any, endpointUrl?: string): any { const existing = docPayload.extensions ?? {} const existingOpencase = (existing['ext:opencase'] && typeof existing['ext:opencase'] === 'object') ? existing['ext:opencase'] @@ -42,7 +43,7 @@ function injectSourceProvenance (docPayload: any, endpointUrl: string): any { ...existing, 'ext:opencase': { ...existingOpencase, - sourcePackageURI: endpointUrl, + ...(endpointUrl ? { sourcePackageURI: endpointUrl } : {}), isModifiedFromSource: false, importedAt: new Date().toISOString(), } @@ -74,7 +75,11 @@ export class ImportFramework { } } else { logger.info({ tenantId, caseVersion }, 'Importing framework from provided JSON') - sourceCFPackage = normalizeCfPackageData(cfPackage) + const normalized = normalizeCfPackageData(cfPackage) + sourceCFPackage = { + ...normalized, + CFDocument: injectSourceProvenance(normalized.CFDocument) + } } // Use CFPackage format directly (matches API response format) @@ -102,17 +107,20 @@ export class ImportFramework { } } - // Create domain entities from CFPackage format - const document = CFDocument.fromRaw(tenantId, caseVersion, payload.CFDocument) + // Create domain entities from CFPackage format. Mirrored imports preserve the + // source's identifiers/URIs as-is rather than rewriting them onto this instance — + // they're only regenerated once the framework is edited and forked (a later task). + const preserveUris = { preserveUris: true } + const document = CFDocument.fromRaw(tenantId, caseVersion, payload.CFDocument, preserveUris) const docId = document.sourcedId const docJSON = document.toJSON() const docURI = docJSON.uri - const items = (payload.CFItems ?? []).map(i => CFItem.fromRaw(tenantId, caseVersion, i, docId, docURI)) + const items = (payload.CFItems ?? []).map(i => CFItem.fromRaw(tenantId, caseVersion, i, docId, docURI, preserveUris)) const associations = (payload.CFAssociations ?? []).map(a => - CFAssociation.fromRaw(tenantId, caseVersion, a) + CFAssociation.fromRaw(tenantId, caseVersion, a, preserveUris) ) const rubrics = (payload.CFRubrics ?? []).map(r => - CFRubric.fromRaw(tenantId, caseVersion, r) + CFRubric.fromRaw(tenantId, caseVersion, r, preserveUris) ) const definitions = payload.CFDefinitions ?? null diff --git a/apps/opencase/src/application/case/endpoints/__tests__/ImportFramework.test.ts b/apps/opencase/src/application/case/endpoints/__tests__/ImportFramework.test.ts index 235e102..a1ceb15 100644 --- a/apps/opencase/src/application/case/endpoints/__tests__/ImportFramework.test.ts +++ b/apps/opencase/src/application/case/endpoints/__tests__/ImportFramework.test.ts @@ -89,7 +89,7 @@ describe('ImportFramework', () => { expect(mockRepository.saveNewVersion).toHaveBeenCalledTimes(1) }) - it('does not inject source provenance when importing from pasted JSON', async () => { + it('marks pasted JSON as a pristine mirror without a sourcePackageURI (no known source URL)', async () => { await importFramework.execute({ tenantId, caseVersion, @@ -99,6 +99,42 @@ describe('ImportFramework', () => { const savedPkg = mockRepository.saveNewVersion.mock.calls[0][2] const savedExtensions = savedPkg.document.toJSON().extensions expect(savedExtensions?.['ext:opencase']?.sourcePackageURI).toBeUndefined() + expect(savedExtensions?.['ext:opencase']?.isModifiedFromSource).toBe(false) + }) + + it('preserves the source uri as-is (does not rewrite it onto this instance) when importing from a URL', async () => { + const foreignDocument = { + ...cfDocument, + uri: 'https://source.example.org/ims/case/v1p1/CFDocuments/doc-123' + } + mockApiClient.fetchCFPackage.mockResolvedValue({ + CFPackage: { CFDocument: foreignDocument, CFItems: [], CFAssociations: [], CFRubrics: [] } + }) + + await importFramework.execute({ + tenantId, + caseVersion, + endpointUrl: 'https://source.example.org/ims/case/v1p1/CFPackages/doc-123' + }) + + const savedPkg = mockRepository.saveNewVersion.mock.calls[0][2] + expect(savedPkg.document.toJSON().uri).toBe('https://source.example.org/ims/case/v1p1/CFDocuments/doc-123') + }) + + it('preserves the source uri as-is when importing from pasted JSON', async () => { + const foreignDocument = { + ...cfDocument, + uri: 'https://source.example.org/ims/case/v1p1/CFDocuments/doc-123' + } + + await importFramework.execute({ + tenantId, + caseVersion, + cfPackage: { CFDocument: foreignDocument } + }) + + const savedPkg = mockRepository.saveNewVersion.mock.calls[0][2] + expect(savedPkg.document.toJSON().uri).toBe('https://source.example.org/ims/case/v1p1/CFDocuments/doc-123') }) it('throws when neither endpointUrl nor cfPackage is provided', async () => { diff --git a/apps/opencase/src/domain/case/entities/CFAssociation.ts b/apps/opencase/src/domain/case/entities/CFAssociation.ts index d156218..1652148 100644 --- a/apps/opencase/src/domain/case/entities/CFAssociation.ts +++ b/apps/opencase/src/domain/case/entities/CFAssociation.ts @@ -28,13 +28,18 @@ export class CFAssociation { return new CFAssociation(props); } - static fromRaw(tenantId: TenantId, caseVersion: CaseVersion, raw: any): CFAssociation { + static fromRaw(tenantId: TenantId, caseVersion: CaseVersion, raw: any, options?: { preserveUris?: boolean }): CFAssociation { // Extract identifier from URN if present (priority over sourcedId/identifier) let identifier = raw.sourcedId || raw.identifier let uri = raw.uri - - // If URI is a URN, extract identifier and transform URI - if (uri && UrnCaseUriHelper.isUrnCaseUri(uri)) { + + if (options?.preserveUris) { + // Mirrored framework: keep the source's identifiers and URIs exactly as supplied. + if (!identifier && uri && UrnCaseUriHelper.isUrnCaseUri(uri)) { + identifier = UrnCaseUriHelper.parseUrnCaseUri(uri)?.identifier || identifier + } + } else if (uri && UrnCaseUriHelper.isUrnCaseUri(uri)) { + // If URI is a URN, extract identifier and transform URI const parsed = UrnCaseUriHelper.parseUrnCaseUri(uri) if (parsed) { identifier = parsed.identifier || identifier @@ -44,45 +49,55 @@ export class CFAssociation { // If not a URN, generate URI based on identifier (existing behavior) uri = this.generateURI(tenantId, caseVersion, identifier) } - - // originNodeURI/destinationNodeURI always reference a CFItem (or CFDocument) - // within the SAME package being imported, so — like CFItem.CFDocumentURI — - // their uri is always regenerated to point at the local host, regardless of - // what URI shape (URN, absolute foreign-host URL, or relative path) the - // source supplied. Only the identifier is trusted from the source data. - let originId = raw.originNodeURI?.identifier ?? raw.originNode ?? 'unknown' - const originUriFromSource = raw.originNodeURI?.uri - if (originUriFromSource && UrnCaseUriHelper.isUrnCaseUri(originUriFromSource)) { - const parsed = UrnCaseUriHelper.parseUrnCaseUri(originUriFromSource) - if (parsed) { - originId = parsed.identifier || originId + + let originNodeURI: LinkData + let destinationNodeURI: LinkData + if (options?.preserveUris && raw.originNodeURI && raw.destinationNodeURI) { + originNodeURI = raw.originNodeURI + destinationNodeURI = raw.destinationNodeURI + } else { + // originNodeURI/destinationNodeURI always reference a CFItem (or CFDocument) + // within the SAME package being imported, so — like CFItem.CFDocumentURI — + // their uri is always regenerated to point at the local host, regardless of + // what URI shape (URN, absolute foreign-host URL, or relative path) the + // source supplied. Only the identifier is trusted from the source data. + let originId = raw.originNodeURI?.identifier ?? raw.originNode ?? 'unknown' + const originUriFromSource = raw.originNodeURI?.uri + if (originUriFromSource && UrnCaseUriHelper.isUrnCaseUri(originUriFromSource)) { + const parsed = UrnCaseUriHelper.parseUrnCaseUri(originUriFromSource) + if (parsed) { + originId = parsed.identifier || originId + } + } + originNodeURI = { + title: raw.originNodeURI?.title ?? String(originId), + identifier: originId, + uri: this.generateItemURI(tenantId, caseVersion, originId) } - } - const originNodeURI = { - title: raw.originNodeURI?.title ?? String(originId), - identifier: originId, - uri: this.generateItemURI(tenantId, caseVersion, originId) - } - let destinationId = raw.destinationNodeURI?.identifier ?? raw.destinationNode ?? 'unknown' - const destinationUriFromSource = raw.destinationNodeURI?.uri - if (destinationUriFromSource && UrnCaseUriHelper.isUrnCaseUri(destinationUriFromSource)) { - const parsed = UrnCaseUriHelper.parseUrnCaseUri(destinationUriFromSource) - if (parsed) { - destinationId = parsed.identifier || destinationId + let destinationId = raw.destinationNodeURI?.identifier ?? raw.destinationNode ?? 'unknown' + const destinationUriFromSource = raw.destinationNodeURI?.uri + if (destinationUriFromSource && UrnCaseUriHelper.isUrnCaseUri(destinationUriFromSource)) { + const parsed = UrnCaseUriHelper.parseUrnCaseUri(destinationUriFromSource) + if (parsed) { + destinationId = parsed.identifier || destinationId + } + } + destinationNodeURI = { + title: raw.destinationNodeURI?.title ?? String(destinationId), + identifier: destinationId, + uri: this.generateItemURI(tenantId, caseVersion, destinationId) } - } - const destinationNodeURI = { - title: raw.destinationNodeURI?.title ?? String(destinationId), - identifier: destinationId, - uri: this.generateItemURI(tenantId, caseVersion, destinationId) } // CFAssociationGroupingURI references a per-tenant definition entity that - // OpenCASE serves itself, so it's rebased onto the local host too. + // OpenCASE serves itself, so it's rebased onto the local host too — unless + // this is a mirrored framework, in which case it's kept as the source supplied it. // CFAssociationGroupingURI must use LinkURI format (UUID identifier required) - const CFAssociationGroupingURI = LinkDataHelper.rebaseLinkData(raw.CFAssociationGroupingURI, caseVersion, 'CFAssociationGroupings') - if (CFAssociationGroupingURI) { + const CFAssociationGroupingURI = options?.preserveUris + ? raw.CFAssociationGroupingURI + : LinkDataHelper.rebaseLinkData(raw.CFAssociationGroupingURI, caseVersion, 'CFAssociationGroupings') + if (CFAssociationGroupingURI && !options?.preserveUris) { LinkDataHelper.validateLinkURI(CFAssociationGroupingURI, 'CFAssociationGroupingURI') } diff --git a/apps/opencase/src/domain/case/entities/CFDocument.ts b/apps/opencase/src/domain/case/entities/CFDocument.ts index 2604f1d..e7ca5b9 100644 --- a/apps/opencase/src/domain/case/entities/CFDocument.ts +++ b/apps/opencase/src/domain/case/entities/CFDocument.ts @@ -39,13 +39,18 @@ export class CFDocument { return new CFDocument(props); } - static fromRaw(tenantId: TenantId, caseVersion: CaseVersion, raw: any): CFDocument { + static fromRaw(tenantId: TenantId, caseVersion: CaseVersion, raw: any, options?: { preserveUris?: boolean }): CFDocument { // Extract identifier from URN if present (priority over sourcedId/identifier) let identifier = raw.sourcedId || raw.identifier let uri = raw.uri - - // If URI is a URN, extract identifier and transform URI - if (uri && UrnCaseUriHelper.isUrnCaseUri(uri)) { + + if (options?.preserveUris) { + // Mirrored framework: keep the source's identifiers and URIs exactly as supplied. + if (!identifier && uri && UrnCaseUriHelper.isUrnCaseUri(uri)) { + identifier = UrnCaseUriHelper.parseUrnCaseUri(uri)?.identifier || identifier + } + } else if (uri && UrnCaseUriHelper.isUrnCaseUri(uri)) { + // If URI is a URN, extract identifier and transform URI const parsed = UrnCaseUriHelper.parseUrnCaseUri(uri) if (parsed) { identifier = parsed.identifier || identifier @@ -55,22 +60,25 @@ export class CFDocument { // If not a URN, generate URI based on identifier (existing behavior) uri = this.generateURI(tenantId, caseVersion, identifier) } - + // Rebase reference URIs onto the local host — these point at per-tenant // definition entities (licenses, packages, subjects) that OpenCASE serves // itself, so they must resolve locally rather than to the source host. - const licenseURI = LinkDataHelper.rebaseLinkData(raw.licenseURI, caseVersion, 'CFLicenses') - const CFPackageURI = LinkDataHelper.rebaseLinkData(raw.CFPackageURI, caseVersion, 'CFPackages') + // Mirrored frameworks skip this and keep the source's reference URIs as-is. + const licenseURI = options?.preserveUris ? raw.licenseURI : LinkDataHelper.rebaseLinkData(raw.licenseURI, caseVersion, 'CFLicenses') + const CFPackageURI = options?.preserveUris ? raw.CFPackageURI : LinkDataHelper.rebaseLinkData(raw.CFPackageURI, caseVersion, 'CFPackages') // subjectURI must use LinkURI format (UUID identifier required) - const subjectURI = Array.isArray(raw.subjectURI) - ? raw.subjectURI.map((s: any) => { - const transformed = LinkDataHelper.rebaseLinkData(s, caseVersion, 'CFSubjects') - if (transformed) { - LinkDataHelper.validateLinkURI(transformed, 'CFDocument.subjectURI') - } - return transformed - }).filter((s: any): s is LinkData => s !== undefined) - : undefined + const subjectURI = options?.preserveUris + ? raw.subjectURI + : Array.isArray(raw.subjectURI) + ? raw.subjectURI.map((s: any) => { + const transformed = LinkDataHelper.rebaseLinkData(s, caseVersion, 'CFSubjects') + if (transformed) { + LinkDataHelper.validateLinkURI(transformed, 'CFDocument.subjectURI') + } + return transformed + }).filter((s: any): s is LinkData => s !== undefined) + : undefined return CFDocument.create({ tenantId, diff --git a/apps/opencase/src/domain/case/entities/CFItem.ts b/apps/opencase/src/domain/case/entities/CFItem.ts index 5c17422..c8976d1 100644 --- a/apps/opencase/src/domain/case/entities/CFItem.ts +++ b/apps/opencase/src/domain/case/entities/CFItem.ts @@ -41,13 +41,18 @@ export class CFItem { return new CFItem(props); } - static fromRaw(tenantId: TenantId, caseVersion: CaseVersion, raw: any, docId?: string, docURI?: string): CFItem { + static fromRaw(tenantId: TenantId, caseVersion: CaseVersion, raw: any, docId?: string, docURI?: string, options?: { preserveUris?: boolean }): CFItem { // Extract identifier from URN if present (priority over sourcedId/identifier) let identifier = raw.sourcedId || raw.identifier let uri = raw.uri - - // If URI is a URN, extract identifier and transform URI - if (uri && UrnCaseUriHelper.isUrnCaseUri(uri)) { + + if (options?.preserveUris) { + // Mirrored framework: keep the source's identifiers and URIs exactly as supplied. + if (!identifier && uri && UrnCaseUriHelper.isUrnCaseUri(uri)) { + identifier = UrnCaseUriHelper.parseUrnCaseUri(uri)?.identifier || identifier + } + } else if (uri && UrnCaseUriHelper.isUrnCaseUri(uri)) { + // If URI is a URN, extract identifier and transform URI const parsed = UrnCaseUriHelper.parseUrnCaseUri(uri) if (parsed) { identifier = parsed.identifier || identifier @@ -57,46 +62,54 @@ export class CFItem { // If not a URN, generate URI based on identifier (existing behavior) uri = this.generateURI(tenantId, caseVersion, identifier) } - - // Transform CFDocumentURI if it's a URN - let docIdentifier = docId ?? raw.CFDocumentURI?.identifier ?? 'unknown' - let generatedDocUri: string - if (raw.CFDocumentURI?.uri && UrnCaseUriHelper.isUrnCaseUri(raw.CFDocumentURI.uri)) { - const parsed = UrnCaseUriHelper.parseUrnCaseUri(raw.CFDocumentURI.uri) - if (parsed) { - docIdentifier = parsed.identifier || docIdentifier - generatedDocUri = UrnCaseUriHelper.urnCaseToRelativePath(raw.CFDocumentURI.uri, caseVersion) + + let CFDocumentURI: LinkData + if (options?.preserveUris && raw.CFDocumentURI) { + CFDocumentURI = raw.CFDocumentURI + } else { + // Transform CFDocumentURI if it's a URN + let docIdentifier = docId ?? raw.CFDocumentURI?.identifier ?? 'unknown' + let generatedDocUri: string + if (raw.CFDocumentURI?.uri && UrnCaseUriHelper.isUrnCaseUri(raw.CFDocumentURI.uri)) { + const parsed = UrnCaseUriHelper.parseUrnCaseUri(raw.CFDocumentURI.uri) + if (parsed) { + docIdentifier = parsed.identifier || docIdentifier + generatedDocUri = UrnCaseUriHelper.urnCaseToRelativePath(raw.CFDocumentURI.uri, caseVersion) + } else { + // Fallback if URN parsing fails + generatedDocUri = docURI ?? this.generateDocumentURI(tenantId, caseVersion, docIdentifier) + } } else { - // Fallback if URN parsing fails generatedDocUri = docURI ?? this.generateDocumentURI(tenantId, caseVersion, docIdentifier) } - } else { - generatedDocUri = docURI ?? this.generateDocumentURI(tenantId, caseVersion, docIdentifier) - } - - const CFDocumentURI = { - title: raw.CFDocumentURI?.title ?? 'Document', - identifier: docIdentifier, - uri: generatedDocUri + + CFDocumentURI = { + title: raw.CFDocumentURI?.title ?? 'Document', + identifier: docIdentifier, + uri: generatedDocUri + } } - + // Rebase reference URIs onto the local host — these point at per-tenant // definition entities (item types, concepts, licenses, subjects) that // OpenCASE serves itself, so they must resolve locally rather than to - // the source host. - const CFItemTypeURI = LinkDataHelper.rebaseLinkData(raw.CFItemTypeURI, caseVersion, 'CFItemTypes') - const conceptKeywordsURI = LinkDataHelper.rebaseLinkData(raw.conceptKeywordsURI, caseVersion, 'CFConcepts') - const licenseURI = LinkDataHelper.rebaseLinkData(raw.licenseURI, caseVersion, 'CFLicenses') + // the source host. Mirrored frameworks skip this and keep the source's + // reference URIs as-is. + const CFItemTypeURI = options?.preserveUris ? raw.CFItemTypeURI : LinkDataHelper.rebaseLinkData(raw.CFItemTypeURI, caseVersion, 'CFItemTypes') + const conceptKeywordsURI = options?.preserveUris ? raw.conceptKeywordsURI : LinkDataHelper.rebaseLinkData(raw.conceptKeywordsURI, caseVersion, 'CFConcepts') + const licenseURI = options?.preserveUris ? raw.licenseURI : LinkDataHelper.rebaseLinkData(raw.licenseURI, caseVersion, 'CFLicenses') // subjectURI must use LinkURI format (UUID identifier required) - const subjectURI = Array.isArray(raw.subjectURI) - ? raw.subjectURI.map((s: any) => { - const transformed = LinkDataHelper.rebaseLinkData(s, caseVersion, 'CFSubjects') - if (transformed) { - LinkDataHelper.validateLinkURI(transformed, 'CFItem.subjectURI') - } - return transformed - }).filter((s: any): s is LinkData => s !== undefined) - : undefined + const subjectURI = options?.preserveUris + ? raw.subjectURI + : Array.isArray(raw.subjectURI) + ? raw.subjectURI.map((s: any) => { + const transformed = LinkDataHelper.rebaseLinkData(s, caseVersion, 'CFSubjects') + if (transformed) { + LinkDataHelper.validateLinkURI(transformed, 'CFItem.subjectURI') + } + return transformed + }).filter((s: any): s is LinkData => s !== undefined) + : undefined return CFItem.create({ tenantId, diff --git a/apps/opencase/src/domain/case/entities/CFRubric.ts b/apps/opencase/src/domain/case/entities/CFRubric.ts index d08a97d..8f66184 100644 --- a/apps/opencase/src/domain/case/entities/CFRubric.ts +++ b/apps/opencase/src/domain/case/entities/CFRubric.ts @@ -23,13 +23,18 @@ export class CFRubric { return new CFRubric(props) } - static fromRaw(tenantId: TenantId, caseVersion: CaseVersion, raw: any): CFRubric { + static fromRaw(tenantId: TenantId, caseVersion: CaseVersion, raw: any, options?: { preserveUris?: boolean }): CFRubric { // Extract identifier from URN if present (priority over identifier/sourcedId) let identifier = raw.identifier || raw.sourcedId || raw.id let uri = raw.uri - - // If URI is a URN, extract identifier and transform URI - if (uri && UrnCaseUriHelper.isUrnCaseUri(uri)) { + + if (options?.preserveUris) { + // Mirrored framework: keep the source's identifiers and URIs exactly as supplied. + if (!identifier && uri && UrnCaseUriHelper.isUrnCaseUri(uri)) { + identifier = UrnCaseUriHelper.parseUrnCaseUri(uri)?.identifier || identifier + } + } else if (uri && UrnCaseUriHelper.isUrnCaseUri(uri)) { + // If URI is a URN, extract identifier and transform URI const parsed = UrnCaseUriHelper.parseUrnCaseUri(uri) if (parsed) { identifier = parsed.identifier || identifier diff --git a/apps/opencase/src/infrastructure/persistence/file/FileCFPackageRepository.ts b/apps/opencase/src/infrastructure/persistence/file/FileCFPackageRepository.ts index 2d02539..361c66a 100644 --- a/apps/opencase/src/infrastructure/persistence/file/FileCFPackageRepository.ts +++ b/apps/opencase/src/infrastructure/persistence/file/FileCFPackageRepository.ts @@ -20,17 +20,23 @@ export class FileCFPackageRepository implements CFPackageRepository { const bundle = await this.store.loadDocumentBundle(tenantId, version, docId) if (!bundle) return null - const document = CFDocument.fromRaw(tenantId, version, bundle.document) + // A framework that is still a pristine mirror of its import source (never + // locally forked) keeps its original identifiers/URIs on every load — they're + // only regenerated once it's edited and marked as forked (a later task). + const opencaseExt = (bundle.document as { extensions?: Record })?.extensions?.['ext:opencase'] as { isModifiedFromSource?: boolean } | undefined + const preserveUris = { preserveUris: opencaseExt?.isModifiedFromSource === false } + + const document = CFDocument.fromRaw(tenantId, version, bundle.document, preserveUris) const docURI = document.toJSON().uri const items = (bundle.items ?? []).map((i: unknown) => - CFItem.fromRaw(tenantId, version, i, docId, docURI) + CFItem.fromRaw(tenantId, version, i, docId, docURI, preserveUris) ) const associations = (bundle.associations ?? []).map((a: unknown) => - CFAssociation.fromRaw(tenantId, version, a) + CFAssociation.fromRaw(tenantId, version, a, preserveUris) ) const rubrics = (bundle.rubrics ?? []).map((r: unknown) => - CFRubric.fromRaw(tenantId, version, r) + CFRubric.fromRaw(tenantId, version, r, preserveUris) ) const definitions = bundle.definitions ?? null diff --git a/apps/opencase/src/infrastructure/persistence/file/__tests__/FileCFPackageRepository.test.ts b/apps/opencase/src/infrastructure/persistence/file/__tests__/FileCFPackageRepository.test.ts index 0f90c97..ab9234f 100644 --- a/apps/opencase/src/infrastructure/persistence/file/__tests__/FileCFPackageRepository.test.ts +++ b/apps/opencase/src/infrastructure/persistence/file/__tests__/FileCFPackageRepository.test.ts @@ -92,6 +92,60 @@ describe('FileCFPackageRepository', () => { expect(result?.rubrics).toEqual([]); }); + it('preserves a pristine mirror\'s original uri across reloads (isModifiedFromSource: false)', async () => { + const bundle = { + document: { + sourcedId: docId, + title: 'Test Document', + uri: 'https://source.example.org/ims/case/v1p1/CFDocuments/doc-123', + lastChangeDateTime: '2024-01-01T00:00:00Z', + extensions: { + 'ext:opencase': { + sourcePackageURI: 'https://source.example.org/ims/case/v1p1/CFPackages/doc-123', + isModifiedFromSource: false + } + } + }, + items: [ + { + sourcedId: 'item-1', + uri: 'https://source.example.org/ims/case/v1p1/CFItems/item-1', + fullStatement: 'Statement 1' + } + ] + }; + + mockStore.loadDocumentBundle.mockResolvedValue(bundle); + + const result = await repository.load(tenantId, version, docId); + + expect(result?.document.toJSON().uri).toBe('https://source.example.org/ims/case/v1p1/CFDocuments/doc-123'); + expect(result?.items[0].toJSON().uri).toBe('https://source.example.org/ims/case/v1p1/CFItems/item-1'); + }); + + it('regenerates local uris for a forked framework (isModifiedFromSource: true)', async () => { + const bundle = { + document: { + sourcedId: docId, + title: 'Test Document', + uri: 'https://source.example.org/ims/case/v1p1/CFDocuments/doc-123', + lastChangeDateTime: '2024-01-01T00:00:00Z', + extensions: { + 'ext:opencase': { + sourcePackageURI: 'https://source.example.org/ims/case/v1p1/CFPackages/doc-123', + isModifiedFromSource: true + } + } + } + }; + + mockStore.loadDocumentBundle.mockResolvedValue(bundle); + + const result = await repository.load(tenantId, version, docId); + + expect(result?.document.toJSON().uri).toBe(`/ims/case/v1p1/CFDocuments/${docId}`); + }); + it('should handle null/undefined items and associations', async () => { const bundle = { document: {