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: {