diff --git a/apps/editor/docs/associations.md b/apps/editor/docs/associations.md new file mode 100644 index 0000000..0913e3b --- /dev/null +++ b/apps/editor/docs/associations.md @@ -0,0 +1,216 @@ +# Associations: import, display and authoring + +How the editor and server handle a framework's associations: what gets imported, where associations are stored, how the Tree View shows and edits them, and what happens on save. The canvas view is disabled and is not covered here. + +--- + +## Terminology + +| Term | Meaning | +|---|---| +| **Inline** | The CFAssociation is stored in the framework's own CFPackage. | +| **Alignment framework** | A separate CFDocument with `frameworkType: "Alignment"` that holds associations from one framework into another. It has no items of its own. | +| **Owner** (source) | The framework whose associations an alignment framework holds. An alignment framework always belongs to exactly one owner. | +| **External association** | An association from one of a framework's items to a node in a *different* framework. It can be stored inline or in an alignment framework. | +| **Hosted** / **remote** | Whether the framework an association points into is on this OpenCASE server (hosted) or not (remote). This is about where the destination lives, not where the association is stored. | +| **Self alignment** | An association between two items of the *same* framework that the author drew in the Tree View (not `isChildOf`). | +| **Structure** | The `isChildOf` associations that make up a framework's tree. They are always inline and are never treated as alignments. | + +"Inline / alignment framework" describes **where an association is stored**. "Hosted / remote" describes **where its destination lives**. The two are independent: an inline association can point at a hosted or a remote framework. + +--- + +## The data + +### A CFAssociation + +```json +{ + "identifier": "", + "associationType": "isRelatedTo", + "originNodeURI": { + "title": "SCI.1 Describe the water cycle.", + "identifier": "", + "uri": "https://case.example.org/ims/case/v1p1/CFItems/" + }, + "destinationNodeURI": { + "title": "MATH.4 Read data from a chart.", + "identifier": "", + "uri": "https://case.example.org/ims/case/v1p1/CFItems/" + } +} +``` + +- Each end names a node by its **item identifier** (or a CFDocument identifier). Neither end says which framework the node belongs to. Finding that out needs the server's item index (see [Hosted or remote](#hosted-or-remote)). +- Each end's `title` is a display label. Its format is the publisher's choice (for example `humanCodingScheme` + `fullStatement`). The editor keeps the title it was given and only generates one (from the item's statement) for associations authored in the editor. +- A CASE 1.1 end can carry `targetType`. Any value other than `CASE` (e.g. `ext:curriculumGoals`) marks a destination that is not a CASE item at all. Such a destination can never be hosted. + +### An alignment framework + +```json +{ + "CFDocument": { + "identifier": "", + "title": "Alignment: Science → Math", + "frameworkType": "Alignment", + "extensions": { + "ext:opencase": { + "alignmentSource": { "identifier": "", "uri": "/ims/case/v1p1/CFDocuments/" }, + "alignmentParticipants": [ + { "identifier": "", "uri": "…" }, + { "identifier": "", "uri": "…" } + ] + } + } + }, + "CFItems": [], + "CFAssociations": [ /* the same shape as inline associations */ ] +} +``` + +- There is one alignment framework per (owner, target framework) pair. They exist so that a framework's associations are split by target instead of piling up in one document. +- `alignmentSource` names the owner. Alignment frameworks saved before it existed always listed the owner first in `alignmentParticipants`, and `alignmentFrameworkSourceId` falls back to that. +- The second participant must be the target **CFDocument identifier**. That is why alignment frameworks can only hold associations to hosted frameworks: for a remote destination we only know the item, not its framework, and we never guess. +- A self alignment's alignment framework names the owner as both participants and is titled `Internal Alignment: `. +- The associations inside are byte-for-byte the same as they would be inline. Moving an association between the two storage locations doesn't change it. + +### The storage setting + +Each framework has `ext:opencase.alignmentStorage` on its CFDocument: + +| Value | Associations to hosted frameworks are stored… | +|---|---| +| `inline` (or absent) | in the framework | +| `alignmentFramework` | in alignment frameworks the framework owns | + +- Associations to **remote** frameworks are always stored inline, whatever the setting. This is a deliberate exception to "a framework stores its external associations one way or the other, never both". Without the target framework on this server there is no alignment framework to put them in. +- Structure and self alignments aren't affected by the setting. +- Authors change it in the framework's properties panel, under **Alignments**. Like `publicAccess`, it lives in `ext:opencase`, so the editor threads it through load and save (`normalizeCasePackage` → domain `FrameworkMetadata.alignmentStorage` → `toCasePackage`). + +--- + +## Import + +`ImportFramework.ts` (server): + +- Keeps every association verbatim: provider `ext:` association types, links to items that aren't on this server, and non-CASE targets. +- Discards any `ext:opencase` that came with the source (OpenCASE's own state is never taken from outside) and writes its own. That includes `alignmentStorage: "inline"`. There is no way to import alignment frameworks, so an imported framework's associations are inline by definition. +- Marks the framework as a mirror (`isModifiedFromSource: false`). See [Mirrors and forks](#mirrors-and-forks). + +Import order matters for hosted vs remote. When imported frameworks link to each other, a link only counts as hosted once its target framework has been imported. + +--- + +## Loading into the editor + +The editor state (`EditorContext`, `editorReducer`) holds a framework as nodes (items and the document) and edges (associations between them). Only associations with **both ends in the framework** become edges. Associations to anything else, including every external association, have no edge. `withOffCanvasAssociations` carries them over from the framework as loaded when the editor saves, so they aren't lost. + +Edges keep their association's stored node titles (`toReactFlow` → `fromEditorGraph`), so a save writes back the titles that were loaded. + +--- + +## Showing associations in the Tree View + +The right-hand panel lists **Associated Frameworks**. Each entry is a target the framework has associations into. It's built from two sources. + +### Inline external associations + +1. `externalAlignmentsOf(framework)` picks the inline associations that start at one of the framework's items and end somewhere outside it. It skips `isChildOf`/`isPartOf` and links to a CFDocument (such as an overview framework's `exactMatchOf` to each subject framework). +2. Their destinations are looked up to find which are hosted (below). +3. `groupExternalAlignments` makes one target per hosted framework (titled from the server) and one per remote host (`goals.example.org (not on this server)`). Remote entries list the destinations themselves, with links to their URIs. + +The view also applies the edits made in this session (`applyInlineAlignmentEdits`), so it shows the associations as they'll be saved. + +### Hosted or remote + +The URI can't tell us: imported associations keep their source's URIs (e.g. `https://case.example.org/…`) even when the target framework has been imported here too. Instead the editor asks the server: + +`POST /management/tenants/{tenantId}/ims/case/{v1p0|v1p1}/CFItems/lookup-documents` with `{ identifiers: [...] }` (at most 10,000) returns `{ [itemId]: { identifier, title } }` for each item a non-archived framework on this server holds. It reads only the server's item and document indexes. Anything not returned is remote. Non-CASE targets are never sent. The Tree View caches answers, misses included, so an edit only looks up new destinations. + +### Alignment frameworks + +On opening, `handleDiscoverAlignedTargets` lists the alignment frameworks that include this framework as a participant and keeps **only the ones it owns**. An alignment framework where this framework is merely the target holds another framework's associations and isn't shown. Each owned one adds its target to the list. Its associations are loaded when the list is built, or when the target is first expanded (`handleLoadAlignmentsForTarget`). + +--- + +## Authoring in the Tree View + +The author expands a target and drags an item from the left panel onto an item on the right. The association type defaults to `isRelatedTo`. The badge popover lets the author change the type (Exact Match Of, Is Related To, Is Peer Of, Precedes, Is Replaced By) or remove the association. `isChildOf` can't be authored here, so it never ends up in an alignment framework. + +Where the new association goes: + +| Target | Setting `inline` | Setting `alignmentFramework` | +|---|---|---| +| Hosted framework | Inline | That target's alignment framework | +| Remote | Inline | Inline | +| This framework (self) | Alignment framework | Alignment framework | + +**Inline** associations are kept in `editorReducer` as `InlineAlignmentEdits` (`added`, `removedIds`, `typeChanges`). They mark the framework dirty, are applied to it when it's saved, and are cleared after a successful save. Every edit is idempotent, so applying it to a framework that already has it changes nothing. + +**Alignment framework** associations are saved separately, with the target's own **Save** button in the Associated Frameworks list. `buildAlignmentFrameworkPackage` writes the alignment framework, owned by this framework. If the last association is removed, the alignment framework is deleted. + +Inline associations to hosted frameworks are read-only while the setting is `alignmentFramework`. That only happens between switching the setting and saving, after which they've moved. + +--- + +## Changing the storage setting + +Switching the setting moves the existing associations to hosted frameworks **when the framework is saved**. Nothing moves if the author switches and then discards. + +`application/framework/services/AlignmentStorage.ts`: + +- `planAlignmentStorage` works out what will move, without changing anything: + - **To alignment frameworks:** the inline associations whose destinations are hosted, grouped by target framework. They're merged into the alignment framework the owner already has for that target, or a new one. + - **To inline:** the associations in every alignment framework the framework owns, except self alignments. +- `saveWithAlignmentStorage` saves the framework and carries out the plan. + +The server won't let the same association identifier be in two frameworks at once (`FileFrameworkStore`). So an association always leaves where it is before it's written where it goes: + +- **Into alignment frameworks:** the framework is saved without the associations, then the alignment frameworks are written. If writing fails, the editor still holds the associations and stays unsaved, so saving again finishes the move. +- **Into the framework:** the alignment frameworks are deleted, then the framework is saved with the associations. If the save fails, the alignment frameworks are recreated from the copies already fetched. + +After a move, the editor reloads the framework from the server and the Tree View rediscovers its alignment frameworks (`alignmentsRevision`). + +--- + +## Mirrors and forks + +An imported framework is a **mirror** of its source until its data is edited. The first save that changes its data **forks** it into an independent copy: the server (`mirrorFork.ts`) generates new identifiers for its document, items and associations. The editor warns first ("Fork this mirrored framework?"). + +What counts as a data change: both the server (`stripNonDataFields`) and the editor (`hasFrameworkDataChanged`) ignore `ext:opencase` (including `publicAccess` and `alignmentStorage`), `lastChangeDateTime`, GET-only back-references, `CFDefinitions`, and the titles on association ends. Which nodes an association connects, and its type, are compared. + +Associations interact with forking in four ways: + +- **Changing the storage setting** is not a data change by itself, but moving associations is. When the setting changed on an unforked mirror, the editor runs `planAlignmentStorage` before deciding whether to warn. The warning only appears if something will move, and it says what: "19 associations to frameworks on this server will move from this framework into alignment frameworks." The same plan is then used for the save. +- **Associations to other frameworks are left alone.** The fork only gives new identifiers to nodes in the framework being forked. An end that refers to a node anywhere else keeps its identifier and URI. +- **Alignment frameworks follow the fork.** The save response includes `forkedIdentifiers` (old → new, for the document and every item). Afterwards the editor rewrites each alignment framework the framework owns to the new identifiers (`remapAlignmentFramework`), and writes any moved associations with them directly. +- **Links from other frameworks break.** Another framework's associations into the forked one still name its old item identifiers, so they now show as remote. This is not handled yet. + +--- + +## Known gaps + +- Forking breaks other frameworks' associations into the forked framework (see above). +- If a save forks the framework and writing an alignment framework then fails, saving again from the same session will go wrong, because the editor still holds the pre-fork identifiers. +- The public `CFItemAssociations` endpoint doesn't return incoming inline associations from other frameworks. +- Remote destinations are grouped by URI host. A host can serve many frameworks, so one entry may cover several. + +--- + +## Where the code is + +| Concern | Location | +|---|---| +| Import (inline setting, `ext:opencase`) | `apps/opencase/src/application/case/endpoints/ImportFramework.ts` | +| Hosted lookup endpoint | `apps/opencase/src/application/case/endpoints/LookupItemDocuments.ts`, `CFItemsManagementController.lookupDocuments` | +| Fork identifiers and data-change detection (server) | `apps/opencase/src/application/case/services/mirrorFork.ts`, `CreateFramework.ts` | +| Inline external associations, hosted/remote grouping | `src/domain/framework/externalAlignments.ts` | +| Alignment framework shape, ownership, merge, fork remap | `src/domain/framework/alignmentFrameworks.ts` | +| Inline edits made in the Tree View | `src/domain/framework/inlineAlignmentEdits.ts`, `editorReducer` (`inlineAlignment/*`) | +| Moving associations on save | `src/application/framework/services/AlignmentStorage.ts` | +| Saving, plan before fork warning | `src/ui/editor/EditorCanvas.tsx` (`handleSave`, `doSave`), `src/app/App.tsx` (`handleSaveToServer`) | +| Data-change detection (editor) | `src/domain/framework/hasFrameworkDataChanged.ts` | +| Associations without an edge | `src/domain/framework/offCanvasAssociations.ts` | +| Association export, node titles | `src/application/framework/mappers/case/toCasePackage.ts` | +| Tree View | `src/ui/editor/treePanel/TreePanelView.tsx` | +| Storage setting UI | `src/ui/editor/components/NodePropertiesPanel.tsx` (Alignments section) | diff --git a/apps/editor/src/app/App.tsx b/apps/editor/src/app/App.tsx index bf3e418..c0633fe 100644 --- a/apps/editor/src/app/App.tsx +++ b/apps/editor/src/app/App.tsx @@ -5,11 +5,13 @@ import HomeScreen from '@/ui/home/HomeScreen' import { createNewFrameworkDraft, createHomeFrameworkFromDomain, loadFrameworks, saveFrameworks, type HomeFramework } from '@/ui/home/frameworkStore' import type { CreateFrameworkDraft } from '@/ui/home/CreateFrameworkDialog' import type { Framework } from '@/domain/framework/model/types' +import { alignmentFrameworkSourceId, alignmentFrameworkTargetId } from '@/domain/framework/alignmentFrameworks' import { AuthProvider, useAuth } from '@/app/providers/AuthProvider' import { getAppConfig } from '@/app/config' import { CaseApiClient, type CfDocumentSummary } from '@/infrastructure/caseApi/CaseApiClient' import { createFetchHttpClient } from '@/infrastructure/caseApi/http' import { loadFrameworkFromCfPackage } from '@/application/framework/services/FrameworkLoader' +import { planAlignmentStorage, saveWithAlignmentStorage, type AlignmentStoragePlan, type AlignmentStoragePort } from '@/application/framework/services/AlignmentStorage' import { toReactFlowGraph, extractLayoutFromCfPackage, extractEditorSettingsFromCfPackage, extractRemoteFrameworkDataFromCfPackage, normalizeLinkedFrameworkColors } from '@/ui/editor/reactflow/mapping' import { frameworkToCfPackage, toOpenCaseFormat } from '@/application/framework/mappers/case/toCasePackage' import type { LayoutState } from '@/ui/editor/reactflow/mapping' @@ -109,6 +111,8 @@ function AppInner() { const [authCallbackState, setAuthCallbackState] = useState<'idle' | 'processing' | 'error'>('idle') const [remoteOpenState, setRemoteOpenState] = useState<'idle' | 'loading'>('idle') + // Bumped when a save moves associations between a framework and its alignment frameworks. + const [alignmentsRevision, setAlignmentsRevision] = useState(0) // Framework ids confirmed against the server this session (fetched from it or just saved to it). // The localStorage cache can hold entries the server no longer has — or never had, e.g. drafts // from before save-on-create — so the editor only opens a framework once it's in this set. @@ -540,19 +544,40 @@ function AppInner() { // Handler to save the CFPackage to the server // Must be defined before early returns (React hooks rules) + // What moving associations between a framework and its alignment frameworks needs from the server. + const alignmentStoragePort = useMemo(() => ({ + listAlignmentFrameworks: (participantId) => api.listAlignmentFrameworks({ tenantId, participantId }), + getCfPackage: (docId) => api.getCfPackage({ docId, caseVersion: 'v1p1' }), + saveCfPackage: (cfPackage) => api.saveCfPackage({ tenantId, cfPackage, caseVersion: 'v1p1' }), + deleteCfPackage: (docId) => api.deleteCfPackage({ tenantId, docId, hardDelete: true }), + lookupItemDocuments: (identifiers) => api.lookupItemDocuments({ tenantId, identifiers, caseVersion: caseApiVersion }), + newIdentifier: () => globalThis.crypto.randomUUID(), + now: () => new Date().toISOString(), + }), [api, tenantId, caseApiVersion]) + + const handlePlanAlignmentStorage = useCallback( + (openCasePackage: unknown, framework: Framework) => + planAlignmentStorage({ framework, cfPackage: openCasePackage as { CFAssociations?: unknown[] }, port: alignmentStoragePort }), + [alignmentStoragePort], + ) + const handleSaveToServer = useCallback( - async (openCasePackage: unknown, framework: Framework) => { + async (openCasePackage: unknown, framework: Framework, plan?: AlignmentStoragePlan) => { if (!tenantId) { throw new Error('Not signed in to a tenant. Please sign in to save.') } console.log('[App] Saving to server:', { tenantId, caseApiVersion }) - const result = await api.saveCfPackage({ - tenantId, - cfPackage: openCasePackage, - caseVersion: caseApiVersion, + // Associations to hosted frameworks are moved to wherever the framework's storage setting says. + const { result, moved } = await saveWithAlignmentStorage({ + framework, + cfPackage: openCasePackage as { CFAssociations?: unknown[] }, + saveFramework: (cfPackage) => api.saveCfPackage({ tenantId, cfPackage, caseVersion: caseApiVersion }), + port: alignmentStoragePort, + plan, }) + if (moved || result.forked) setAlignmentsRevision((r) => r + 1) // A fork mints new identifiers server-side for the document AND every // item/association in it — not just the document. Patching the local @@ -610,11 +635,14 @@ function AppInner() { saveFrameworks(next) return next }) + + // The saved framework's associations aren't the ones just edited: some moved in or out. + if (moved) await openRemoteFramework(activeFrameworkId, { replace: true }) } console.log('[App] Saved successfully') }, - [api, tenantId, caseApiVersion, activeFrameworkId, openRemoteFramework, mergeCfDefinitions], + [api, tenantId, caseApiVersion, activeFrameworkId, openRemoteFramework, mergeCfDefinitions, alignmentStoragePort], ) const handleSaveAlignments = useCallback( @@ -642,13 +670,12 @@ function AppInner() { if (!tenantId) return [] try { const alignmentDocs = await api.listAlignmentFrameworks({ tenantId, participantId: sourceId }) + // Only alignment frameworks this framework owns; a self (intra-framework) one targets itself. return alignmentDocs.flatMap((doc) => { - const participants = doc.extensions?.['ext:opencase']?.alignmentParticipants ?? [] - const other = participants.find((p) => p.identifier !== sourceId) - // No distinct "other" participant means this is a self (intra-framework) alignment doc. - const targetIdentifier = other?.identifier ?? (participants.some((p) => p.identifier === sourceId) ? sourceId : undefined) - if (!targetIdentifier) return [] - return [{ targetId: targetIdentifier, alignmentDocId: doc.sourcedId }] + const ext = doc.extensions?.['ext:opencase'] + const targetId = alignmentFrameworkTargetId(ext) + if (alignmentFrameworkSourceId(ext) !== sourceId || !targetId) return [] + return [{ targetId, alignmentDocId: doc.sourcedId }] }) } catch (err) { console.warn('[App] Failed to discover aligned targets:', err) @@ -658,6 +685,13 @@ function AppInner() { [api, tenantId], ) + // Which hosted framework holds each item — used by the tree view to place a framework's + // inline associations to items outside it (anything unresolved is shown as remote). + const handleLookupItemDocuments = useCallback( + (identifiers: string[]) => api.lookupItemDocuments({ tenantId, identifiers, caseVersion: caseApiVersion }), + [api, tenantId, caseApiVersion], + ) + const handleLoadAlignmentsForTarget = useCallback( async (targetId: string): Promise<{ docId: string @@ -667,15 +701,11 @@ function AppInner() { if (!tenantId || !activeFrameworkId) return { docId: newDocId(), associations: [] } try { const alignmentDocs = await api.listAlignmentFrameworks({ tenantId, participantId: activeFrameworkId }) - // Every doc here already has activeFrameworkId as a participant (that's the query filter), so - // for a self-alignment (targetId === activeFrameworkId) matching "any participant === targetId" - // would match the first cross-framework doc too — instead require ALL participants to be self. + // Only an alignment framework this framework owns holds its associations — one where it is + // merely the target holds the other framework's. const matchingDoc = alignmentDocs.find((doc) => { - const participants = doc.extensions?.['ext:opencase']?.alignmentParticipants ?? [] - if (targetId === activeFrameworkId) { - return participants.length > 0 && participants.every((p) => p.identifier === activeFrameworkId) - } - return participants.some((p) => p.identifier === targetId) + const ext = doc.extensions?.['ext:opencase'] + return alignmentFrameworkSourceId(ext) === activeFrameworkId && alignmentFrameworkTargetId(ext) === targetId }) if (!matchingDoc) return { docId: newDocId(), associations: [] } @@ -766,6 +796,7 @@ function AppInner() { navigateHome()} onSaveToServer={tenantId ? handleSaveToServer : undefined} + onPlanAlignmentStorage={tenantId ? handlePlanAlignmentStorage : undefined} onArchiveFramework={tenantId && activeFrameworkId ? handleArchiveFramework : undefined} onFetchCfPackage={activeFrameworkId ? handleFetchCfPackage : undefined} availableFrameworks={frameworks} @@ -774,10 +805,12 @@ function AppInner() { onSaveAlignments={tenantId ? handleSaveAlignments : undefined} onLoadAlignmentsForTarget={tenantId ? handleLoadAlignmentsForTarget : undefined} onDiscoverAlignedTargets={tenantId ? handleDiscoverAlignedTargets : undefined} + onLookupItemDocuments={handleLookupItemDocuments} mirrorStatus={activeFramework.mirrorStatus} readOnly={activeFramework.readOnly} cfDefinitionsExtensions={activeCfDefinitions?.extensions} loadedFramework={activeFramework.framework} + alignmentsRevision={alignmentsRevision} /> ) diff --git a/apps/editor/src/application/framework/mappers/case/CasePackageSnapshot.ts b/apps/editor/src/application/framework/mappers/case/CasePackageSnapshot.ts index f0fabeb..1ddede8 100644 --- a/apps/editor/src/application/framework/mappers/case/CasePackageSnapshot.ts +++ b/apps/editor/src/application/framework/mappers/case/CasePackageSnapshot.ts @@ -1,3 +1,5 @@ +import type { AlignmentStorage } from '@/domain/framework/model/types' + export type CaseVersion = '1.0' | '1.1' | 'unknown' /** @@ -38,6 +40,8 @@ export type CaseDocumentSnapshot = { licenseURI?: { title?: string; identifier?: string; uri: string } /** OpenCASE: unauthenticated CASE API reads. Absent means sign-in is required. */ publicAccess?: boolean + /** OpenCASE: where associations to other frameworks are stored. Absent means inline. */ + alignmentStorage?: AlignmentStorage /** * Non-OpenCASE CASE extensions (e.g. a source system's identifiers), preserved * verbatim for round-trip fidelity. `ext:opencase` is excluded — the editor diff --git a/apps/editor/src/application/framework/mappers/case/caseToDomainFramework.ts b/apps/editor/src/application/framework/mappers/case/caseToDomainFramework.ts index 1dd7b97..dfff902 100644 --- a/apps/editor/src/application/framework/mappers/case/caseToDomainFramework.ts +++ b/apps/editor/src/application/framework/mappers/case/caseToDomainFramework.ts @@ -57,6 +57,7 @@ export function mapCaseSnapshotToDomainFramework(snapshot: CasePackageSnapshot): lastChangeDateTime: doc.lastChangeDateTime, licenseURI: doc.licenseURI, publicAccess: doc.publicAccess === true ? true : undefined, + alignmentStorage: doc.alignmentStorage, extensions: doc.extensions, } diff --git a/apps/editor/src/application/framework/mappers/case/normalizeCasePackage.ts b/apps/editor/src/application/framework/mappers/case/normalizeCasePackage.ts index 7e5eabc..59f61ef 100644 --- a/apps/editor/src/application/framework/mappers/case/normalizeCasePackage.ts +++ b/apps/editor/src/application/framework/mappers/case/normalizeCasePackage.ts @@ -309,6 +309,9 @@ export function normalizeCasePackageResponse(res: unknown): CasePackageSnapshot const { 'ext:opencase': rawOpencaseExt, ...customDocExtensions } = asRecord(doc.extensions) ?? {} const opencaseExt = asRecord(rawOpencaseExt) const publicAccess = opencaseExt?.publicAccess === true ? true : undefined + const alignmentStorage = opencaseExt?.alignmentStorage === 'inline' || opencaseExt?.alignmentStorage === 'alignmentFramework' + ? opencaseExt.alignmentStorage + : undefined // Extract CFAssociationGroupings from CFDefinitions const defs = asRecord(pkg.CFDefinitions) @@ -354,6 +357,7 @@ export function normalizeCasePackageResponse(res: unknown): CasePackageSnapshot lastChangeDateTime: asString(doc.lastChangeDateTime), licenseURI, publicAccess, + alignmentStorage, extensions: Object.keys(customDocExtensions).length > 0 ? customDocExtensions : undefined, }, items, diff --git a/apps/editor/src/application/framework/mappers/case/toCasePackage.extensionsRoundTrip.test.ts b/apps/editor/src/application/framework/mappers/case/toCasePackage.extensionsRoundTrip.test.ts index ccbd2de..07829e7 100644 --- a/apps/editor/src/application/framework/mappers/case/toCasePackage.extensionsRoundTrip.test.ts +++ b/apps/editor/src/application/framework/mappers/case/toCasePackage.extensionsRoundTrip.test.ts @@ -120,4 +120,15 @@ describe('CASE extensions round-trip through the editor', () => { expect(hasFrameworkDataChanged(framework, layoutOnly)).toBe(false) expect(hasFrameworkDataChanged(framework, customEdit)).toBe(true) }) + + it('keeps the association storage setting through the editor', () => { + const pkg = makePackage() + pkg.CFDocument.extensions = { 'ext:opencase': { alignmentStorage: 'alignmentFramework' } } + const framework = loadFrameworkFromCfPackage(pkg) + if (!framework) throw new Error('failed to load framework') + + expect(framework.metadata.alignmentStorage).toBe('alignmentFramework') + const saved = saveFromEditor(toReactFlowGraph({ framework })) + expect(saved.CFDocument.extensions).toEqual({ 'ext:opencase': expect.objectContaining({ alignmentStorage: 'alignmentFramework' }) }) + }) }) diff --git a/apps/editor/src/application/framework/mappers/case/toCasePackage.targetTypeRoundTrip.test.ts b/apps/editor/src/application/framework/mappers/case/toCasePackage.targetTypeRoundTrip.test.ts index e6b5ee2..bdfe05d 100644 --- a/apps/editor/src/application/framework/mappers/case/toCasePackage.targetTypeRoundTrip.test.ts +++ b/apps/editor/src/application/framework/mappers/case/toCasePackage.targetTypeRoundTrip.test.ts @@ -13,8 +13,8 @@ const ITEM_ID = '22222222-2222-4222-8222-222222222222' const EXTERNAL_DEST = { title: 'KO 3.1.2 De kleuters kunnen:', identifier: 'KO|3.1.2', - uri: 'https://onderwijsdoelen.be/doelen/KO_V2_0?vakdiscipline=Wetenschap%20en%20techniek#3.1.2', - targetType: 'ext:onderwijsdoelen', + uri: 'https://goals.example.org/goals/KO_V2_0?subject=Science#3.1.2', + targetType: 'ext:curriculumGoals', } // A CASE item in another framework, explicitly marked targetType CASE. @@ -99,6 +99,14 @@ describe('association node targetType round-trip (#49)', () => { expect(assocById(openCase, '66666666-6666-4666-8666-666666666666')?.destinationNodeURI).toEqual(CASE_DEST) }) + it("keeps the source's title on a node in this framework rather than rebuilding it from the item", () => { + const pkg = makePackage() + const assoc = pkg.CFAssociations![2] + assoc.originNodeURI = { ...assoc.originNodeURI, title: 'SCI.1 Item 1' } + const { openCase } = saveFromEditor(pkg) + expect(assocById(openCase, '66666666-6666-4666-8666-666666666666')?.originNodeURI.title).toBe('SCI.1 Item 1') + }) + it('does not add a targetType to nodes that had none', () => { const { openCase } = saveFromEditor() const assoc = assocById(openCase, '44444444-4444-4444-8444-444444444444') diff --git a/apps/editor/src/application/framework/mappers/case/toCasePackage.ts b/apps/editor/src/application/framework/mappers/case/toCasePackage.ts index 2efc13c..3c3521b 100644 --- a/apps/editor/src/application/framework/mappers/case/toCasePackage.ts +++ b/apps/editor/src/application/framework/mappers/case/toCasePackage.ts @@ -1,5 +1,5 @@ import type { CFAssociation, CFAssociationGrouping, CFConcept, CFDefinition, CFDocument, CFItem, CFItemType, CFLicense, CFSubject, CFPackage, CaseExtensions, LinkURI } from '@/domain/case/types' -import type { Framework } from '@/domain/framework/model/types' +import type { AlignmentStorage, Framework } from '@/domain/framework/model/types' import type { CaseVersion } from './CasePackageSnapshot' import type { LayoutState, NodeLayout } from '@/ui/editor/reactflow/mapping/types' @@ -98,6 +98,7 @@ type OpencaseExtension = { linkedFrameworks?: unknown[] remoteItemLinks?: unknown[] publicAccess?: boolean + alignmentStorage?: AlignmentStorage } /** @@ -111,7 +112,7 @@ function mergeOpencaseExtension( const extensions = { ...base } // Only add if there's data to store - if (opencaseData.layout || opencaseData.notes || opencaseData.originHandle || opencaseData.destinationHandle || opencaseData.edgeType || opencaseData.colorBand || opencaseData.linkedFrameworks || opencaseData.remoteItemLinks || opencaseData.publicAccess) { + if (opencaseData.layout || opencaseData.notes || opencaseData.originHandle || opencaseData.destinationHandle || opencaseData.edgeType || opencaseData.colorBand || opencaseData.linkedFrameworks || opencaseData.remoteItemLinks || opencaseData.publicAccess || opencaseData.alignmentStorage) { const existing = (extensions[OPENCASE_EXT_KEY] as OpencaseExtension | undefined) ?? {} extensions[OPENCASE_EXT_KEY] = { ...existing, @@ -166,13 +167,14 @@ function frameworkToCfDocument( }, // meta.extensions holds the document's non-OpenCASE extensions (never ext:opencase), // so ext:opencase is always regenerated from editor state while the rest round-trip. - extensions: (layout || options?.edgeType || options?.linkedFrameworks || options?.remoteItemLinks || meta.publicAccess === true) + extensions: (layout || options?.edgeType || options?.linkedFrameworks || options?.remoteItemLinks || meta.publicAccess === true || meta.alignmentStorage) ? mergeOpencaseExtension(meta.extensions, { layout, edgeType: options?.edgeType, linkedFrameworks: options?.linkedFrameworks, remoteItemLinks: options?.remoteItemLinks, ...(meta.publicAccess === true ? { publicAccess: true } : {}), + ...(meta.alignmentStorage ? { alignmentStorage: meta.alignmentStorage } : {}), }) : meta.extensions, } @@ -281,8 +283,9 @@ function associationToCfAssociation( return typeof v === 'number' ? v : undefined } - const fromTitle = fromItem?.statement ?? s('originTitle') ?? `Item ${fromId}` - const toTitle = toItem?.statement ?? s('destinationTitle') ?? `Item ${toId}` + // A node's title is kept as it arrived; one is only made up for associations authored here. + const fromTitle = s('originTitle') ?? fromItem?.statement ?? `Item ${fromId}` + const toTitle = s('destinationTitle') ?? toItem?.statement ?? `Item ${toId}` const originTargetType = s('originTargetType') const destinationTargetType = s('destinationTargetType') diff --git a/apps/editor/src/application/framework/services/AlignmentStorage.test.ts b/apps/editor/src/application/framework/services/AlignmentStorage.test.ts new file mode 100644 index 0000000..a239412 --- /dev/null +++ b/apps/editor/src/application/framework/services/AlignmentStorage.test.ts @@ -0,0 +1,226 @@ +import { describe, it, expect } from 'vitest' +import { alignmentMoveCount, planAlignmentStorage, saveWithAlignmentStorage, type AlignmentStoragePort } from './AlignmentStorage' +import { buildAlignmentFrameworkPackage } from '@/domain/framework/alignmentFrameworks' +import type { CFAssociation } from '@/domain/case/types' +import type { AlignmentStorage, Association, Framework, Item } from '@/domain/framework/model/types' +import type { AssociationId, FrameworkId, ItemId } from '@/domain/shared/types' + +// Science (doc-sci) aligns to Math (doc-math, hosted) and to a curriculum goal on goals.example.org (remote, non-CASE). +const toMath: CFAssociation = { + identifier: 'to-math', + associationType: 'isRelatedTo', + uri: '/ims/case/v1p1/CFAssociations/to-math', + originNodeURI: { title: 'SCI.1', identifier: 'sci-1', uri: '/ims/case/v1p1/CFItems/sci-1' }, + destinationNodeURI: { title: 'MATH.1', identifier: 'math-1', uri: '/ims/case/v1p1/CFItems/math-1' }, + lastChangeDateTime: '2026-10-30T00:00:00Z', +} +const toGoals: CFAssociation = { + identifier: 'to-goal', + associationType: 'isRelatedTo', + uri: '/ims/case/v1p1/CFAssociations/to-goal', + originNodeURI: { title: 'SCI.1', identifier: 'sci-1', uri: '/ims/case/v1p1/CFItems/sci-1' }, + destinationNodeURI: { title: 'Goal 8.3.1', identifier: 'goal-8.3.1', uri: 'https://goals.example.org/goals#8.3.1', targetType: 'ext:curriculumGoals' }, + lastChangeDateTime: '2026-10-30T00:00:00Z', +} + +const domainAssociation = (a: CFAssociation): Association => ({ + id: a.identifier as AssociationId, + fromItemId: a.originNodeURI.identifier as ItemId, + toItemId: a.destinationNodeURI.identifier as ItemId, + associationType: a.associationType as Association['associationType'], + metadata: { + originUri: a.originNodeURI.uri, + destinationUri: a.destinationNodeURI.uri, + ...(a.destinationNodeURI.targetType ? { destinationTargetType: a.destinationNodeURI.targetType } : {}), + }, +}) + +const science = (alignmentStorage: AlignmentStorage, associations: CFAssociation[]): Framework => ({ + id: 'doc-sci' as FrameworkId, + metadata: { title: 'Science', alignmentStorage }, + items: new Map([['sci-1' as ItemId, { id: 'sci-1' as ItemId, statement: 'SCI.1' }]]), + associations: new Map(associations.map((a) => [a.identifier as AssociationId, domainAssociation(a)])), + status: 'Published', +}) + +type StoredPackage = { CFDocument: { identifier: string; extensions?: Record }; CFAssociations?: CFAssociation[] } + +// Holds Science and its alignment frameworks. Like the real server, an association can only be in one of them. +function fakeServer(scienceAssociations: CFAssociation[], alignmentFrameworks: ReturnType[] = []) { + const stored = new Map(alignmentFrameworks.map((p) => [p.CFDocument.identifier, p])) + stored.set('doc-sci', { CFDocument: { identifier: 'doc-sci' }, CFAssociations: scienceAssociations }) + const save = (pkg: StoredPackage) => { + for (const other of stored.values()) { + if (other.CFDocument.identifier === pkg.CFDocument.identifier) continue + const clash = other.CFAssociations?.find((a) => pkg.CFAssociations?.some((b) => b.identifier === a.identifier)) + if (clash) throw new Error(`CFAssociation id '${clash.identifier}' is already used in a different framework`) + } + stored.set(pkg.CFDocument.identifier, structuredClone(pkg)) + } + let ids = 0 + const port: AlignmentStoragePort = { + listAlignmentFrameworks: async (participantId) => + [...stored.values()] + .filter((p) => p.CFDocument.identifier !== 'doc-sci' && JSON.stringify(p.CFDocument.extensions).includes(`"${participantId}"`)) + .map((p) => ({ sourcedId: p.CFDocument.identifier, extensions: p.CFDocument.extensions })), + getCfPackage: async (docId) => structuredClone(stored.get(docId)!), + saveCfPackage: async (pkg) => save(pkg as StoredPackage), + deleteCfPackage: async (docId) => { stored.delete(docId) }, + lookupItemDocuments: async (identifiers) => + Object.fromEntries(identifiers.filter((id) => id === 'math-1').map((id) => [id, { identifier: 'doc-math', title: 'Math' }])), + newIdentifier: () => `af-new-${++ids}`, + now: () => '2026-10-30T00:00:00Z', + } + const saveFramework = async (pkg: { CFAssociations?: unknown[] }) => { + save({ CFDocument: { identifier: 'doc-sci' }, CFAssociations: pkg.CFAssociations as CFAssociation[] }) + return { docId: 'doc-sci' } + } + return { port, stored, saveFramework } +} + +const alignmentFramework = (targetId: string, associations: CFAssociation[]) => buildAlignmentFrameworkPackage({ + identifier: `af-${targetId}`, + source: { identifier: 'doc-sci', title: 'Science' }, + target: { identifier: targetId, title: targetId }, + creator: 'OpenCASE', + associations, + now: '2026-10-30T00:00:00Z', +}) + +const forked = { docId: 'doc-sci-2', forkedIdentifiers: { 'doc-sci': 'doc-sci-2', 'sci-1': 'sci-1b' } } + +const associationIds = (pkg: { CFAssociations?: unknown[] } | undefined) => + (pkg?.CFAssociations as CFAssociation[] | undefined ?? []).map((a) => a.identifier) + +describe('saveWithAlignmentStorage', () => { + it('moves associations to hosted frameworks into alignment frameworks; remote ones stay inline', async () => { + const { port, stored, saveFramework } = fakeServer([toMath, toGoals]) + const { moved } = await saveWithAlignmentStorage({ + framework: science('alignmentFramework', [toMath, toGoals]), + cfPackage: { CFAssociations: [toMath, toGoals] }, + saveFramework, + port, + }) + + expect(moved).toBe(true) + expect(associationIds(stored.get('doc-sci'))).toEqual(['to-goal']) + const af = stored.get('af-new-1')! + expect(af.CFDocument.extensions?.['ext:opencase']).toMatchObject({ alignmentSource: { identifier: 'doc-sci' } }) + expect(associationIds(af)).toEqual(['to-math']) + }) + + it('adds to the alignment framework it already owns for that target', async () => { + const earlier = { ...toMath, identifier: 'earlier' } + const { port, stored, saveFramework } = fakeServer([toMath], [alignmentFramework('doc-math', [earlier])]) + await saveWithAlignmentStorage({ + framework: science('alignmentFramework', [toMath]), + cfPackage: { CFAssociations: [toMath] }, + saveFramework, + port, + }) + + expect([...stored.keys()].sort()).toEqual(['af-doc-math', 'doc-sci']) + expect(associationIds(stored.get('af-doc-math'))).toEqual(['earlier', 'to-math']) + }) + + it('moves associations from its alignment frameworks inline and deletes them, leaving self alignments', async () => { + const self = { ...toMath, identifier: 'self', destinationNodeURI: { ...toMath.originNodeURI } } + const { port, stored, saveFramework } = fakeServer( + [toGoals], + [alignmentFramework('doc-math', [toMath]), alignmentFramework('doc-sci', [self])], + ) + const { moved } = await saveWithAlignmentStorage({ + framework: science('inline', [toGoals]), + cfPackage: { CFAssociations: [toGoals] }, + saveFramework, + port, + }) + + expect(moved).toBe(true) + expect(associationIds(stored.get('doc-sci'))).toEqual(['to-goal', 'to-math']) + expect([...stored.keys()].sort()).toEqual(['af-doc-sci', 'doc-sci']) + }) + + it('puts the alignment frameworks back when the framework fails to save', async () => { + const { port, stored } = fakeServer([], [alignmentFramework('doc-math', [toMath])]) + await expect(saveWithAlignmentStorage({ + framework: science('inline', []), + cfPackage: { CFAssociations: [] }, + saveFramework: async () => { throw new Error('offline') }, + port, + })).rejects.toThrow('offline') + + expect(associationIds(stored.get('af-doc-math'))).toEqual(['to-math']) + }) + + it('moves nothing when storage already matches the setting', async () => { + const { port, saveFramework } = fakeServer([toMath]) + const { moved } = await saveWithAlignmentStorage({ + framework: science('inline', [toMath]), + cfPackage: { CFAssociations: [toMath] }, + saveFramework, + port, + }) + expect(moved).toBe(false) + }) + + it('writes moved associations with the identifiers the fork gave the framework', async () => { + const { port, stored, saveFramework } = fakeServer([toMath]) + await saveWithAlignmentStorage({ + framework: science('alignmentFramework', [toMath]), + cfPackage: { CFAssociations: [toMath] }, + saveFramework: async (pkg) => ({ ...await saveFramework(pkg), ...forked }), + port, + }) + + const af = stored.get('af-new-1')! + expect(af.CFDocument.extensions?.['ext:opencase']).toMatchObject({ alignmentSource: { identifier: 'doc-sci-2' } }) + expect(af.CFAssociations?.[0].originNodeURI.identifier).toBe('sci-1b') + }) + + it('keeps its alignment frameworks pointing at it when the save forks it', async () => { + const { port, stored, saveFramework } = fakeServer([], [alignmentFramework('doc-math', [toMath])]) + await saveWithAlignmentStorage({ + framework: science('alignmentFramework', []), + cfPackage: { CFAssociations: [] }, + saveFramework: async (pkg) => ({ ...await saveFramework(pkg), ...forked }), + port, + }) + + const af = stored.get('af-doc-math')! + expect(af.CFDocument.extensions?.['ext:opencase']).toMatchObject({ + alignmentSource: { identifier: 'doc-sci-2' }, + alignmentParticipants: [{ identifier: 'doc-sci-2' }, { identifier: 'doc-math' }], + }) + expect(af.CFAssociations?.[0].originNodeURI.identifier).toBe('sci-1b') + }) +}) + +describe('planAlignmentStorage', () => { + it('counts the associations a save will move, without changing anything', async () => { + const { port, stored } = fakeServer([toMath, toGoals]) + const plan = await planAlignmentStorage({ + framework: science('alignmentFramework', [toMath, toGoals]), + cfPackage: { CFAssociations: [toMath, toGoals] }, + port, + }) + expect(alignmentMoveCount(plan)).toBe(1) + expect([...stored.keys()]).toEqual(['doc-sci']) + }) + + it('counts associations coming back from alignment frameworks', async () => { + const { port } = fakeServer([], [alignmentFramework('doc-math', [toMath, { ...toMath, identifier: 'to-math-2' }])]) + const plan = await planAlignmentStorage({ framework: science('inline', []), cfPackage: { CFAssociations: [] }, port }) + expect(alignmentMoveCount(plan)).toBe(2) + }) + + it('moves nothing when only remote associations are inline', async () => { + const { port } = fakeServer([toGoals]) + const plan = await planAlignmentStorage({ + framework: science('alignmentFramework', [toGoals]), + cfPackage: { CFAssociations: [toGoals] }, + port, + }) + expect(plan.kind).toBe('none') + }) +}) diff --git a/apps/editor/src/application/framework/services/AlignmentStorage.ts b/apps/editor/src/application/framework/services/AlignmentStorage.ts new file mode 100644 index 0000000..85b6093 --- /dev/null +++ b/apps/editor/src/application/framework/services/AlignmentStorage.ts @@ -0,0 +1,191 @@ +import type { CFAssociation } from '@/domain/case/types' +import type { Framework } from '@/domain/framework/model/types' +import { + alignmentFrameworkSourceId, + alignmentFrameworkTargetId, + buildAlignmentFrameworkPackage, + mergeAssociations, + remapAlignmentFramework, + type AlignmentFrameworkExtensions, + type AlignmentFrameworkPackage, +} from '@/domain/framework/alignmentFrameworks' +import { + externalAlignmentsOf, + groupExternalAlignments, + hostedLookupCandidates, + type HostedDocumentRef, +} from '@/domain/framework/externalAlignments' + +/** What saving a framework with its alignment storage needs from the server. */ +export type AlignmentStoragePort = { + listAlignmentFrameworks(participantId: string): Promise }>> + getCfPackage(docId: string): Promise<{ CFDocument: { extensions?: Record }; CFAssociations?: CFAssociation[] }> + saveCfPackage(cfPackage: unknown): Promise + deleteCfPackage(docId: string): Promise + lookupItemDocuments(identifiers: string[]): Promise> + newIdentifier(): string + now(): string +} + +type FrameworkPackage = { CFAssociations?: unknown[] } +type SaveResult = { docId: string; forkedIdentifiers?: Record } +type StoredPackage = Awaited> + +/** + * What saving a framework will do to bring its associations to hosted + * frameworks in line with its `alignmentStorage` setting — worked out without + * changing anything, so the author can be told before it happens. + */ +export type AlignmentStoragePlan = + | { kind: 'none' } + | { + kind: 'toAlignmentFrameworks' + /** Inline associations leaving the framework. */ + movedIds: Set + /** The alignment frameworks they go into, as they'll be saved. */ + alignmentFrameworks: AlignmentFrameworkPackage[] + } + | { + kind: 'toInline' + /** The alignment frameworks the associations come from, as stored. */ + alignmentFrameworks: Array<{ docId: string; pkg: StoredPackage }> + } + +/** How many associations a plan moves. */ +export function alignmentMoveCount(plan: AlignmentStoragePlan): number { + switch (plan.kind) { + case 'none': return 0 + case 'toAlignmentFrameworks': return plan.movedIds.size + case 'toInline': return plan.alignmentFrameworks.reduce((n, { pkg }) => n + (pkg.CFAssociations?.length ?? 0), 0) + } +} + +/** + * Which associations to hosted frameworks need to move for the framework's + * storage setting: inline ones into the alignment frameworks it owns (one per + * target framework), or the other way. Associations to remote frameworks + * always stay inline — without the target framework on this server there's + * no alignment framework to put them in. Self alignments aren't associations + * to another framework, so they stay where they are. + */ +export async function planAlignmentStorage(params: { + framework: Framework + cfPackage: FrameworkPackage + port: AlignmentStoragePort +}): Promise { + const { framework, cfPackage, port } = params + const sourceId = String(framework.id) + const owned = await ownedAlignmentFrameworks(port, sourceId) + + if ((framework.metadata.alignmentStorage ?? 'inline') === 'alignmentFramework') { + const alignments = externalAlignmentsOf(framework) + const candidates = hostedLookupCandidates(alignments) + const hostedDocuments = candidates.length > 0 ? await port.lookupItemDocuments(candidates) : {} + const hostedTargets = groupExternalAlignments(alignments, hostedDocuments).filter((t) => t.kind === 'hosted') + if (hostedTargets.length === 0) return { kind: 'none' } + + const byId = new Map(((cfPackage.CFAssociations ?? []) as CFAssociation[]).map((a) => [a.identifier, a])) + const alignmentFrameworks = await Promise.all(hostedTargets.map(async (target) => { + const existing = owned.find((o) => o.targetId === target.id) + const stored = existing ? (await port.getCfPackage(existing.docId)).CFAssociations ?? [] : [] + return buildAlignmentFrameworkPackage({ + identifier: existing?.docId ?? port.newIdentifier(), + source: { identifier: sourceId, title: framework.metadata.title ?? sourceId }, + target: { identifier: target.id, title: target.title }, + creator: framework.metadata.creator ?? 'OpenCASE', + associations: mergeAssociations(stored, target.alignments.flatMap((a) => byId.get(a.id) ?? [])), + now: port.now(), + }) + })) + return { + kind: 'toAlignmentFrameworks', + movedIds: new Set(hostedTargets.flatMap((t) => t.alignments.map((a) => a.id))), + alignmentFrameworks, + } + } + + const toOtherFrameworks = owned.filter((o) => o.targetId !== sourceId) + if (toOtherFrameworks.length === 0) return { kind: 'none' } + return { + kind: 'toInline', + alignmentFrameworks: await Promise.all(toOtherFrameworks.map(async ({ docId }) => ({ docId, pkg: await port.getCfPackage(docId) }))), + } +} + +/** + * Saves a framework, carrying out `plan` (worked out here when not given). + * + * The server won't let an association be in two frameworks at once, so + * associations leave where they are before they're written where they go: + * - Into alignment frameworks: the framework is saved without them first. If + * writing an alignment framework then fails, the caller still has them, so + * saving again finishes the move. + * - Into the framework: its alignment frameworks are deleted first, and put + * back if the framework then fails to save. + * + * When the save forks the framework, the alignment frameworks it owns follow + * it to its new identifiers. + * + * `moved` is true when associations changed place, so the caller should + * reload what it shows. + */ +export async function saveWithAlignmentStorage

(params: { + framework: Framework + cfPackage: P + saveFramework: (cfPackage: P) => Promise + port: AlignmentStoragePort + plan?: AlignmentStoragePlan +}): Promise<{ result: R; moved: boolean }> { + const { framework, cfPackage, saveFramework, port } = params + const sourceId = String(framework.id) + const plan = params.plan ?? await planAlignmentStorage({ framework, cfPackage, port }) + const inline = (cfPackage.CFAssociations ?? []) as CFAssociation[] + + if (plan.kind === 'toAlignmentFrameworks') { + const result = await saveFramework({ ...cfPackage, CFAssociations: inline.filter((a) => !plan.movedIds.has(a.identifier)) }) + for (const pkg of plan.alignmentFrameworks) { + await port.saveCfPackage(result.forkedIdentifiers ? remapAlignmentFramework(pkg, result.forkedIdentifiers) ?? pkg : pkg) + } + await followFork(port, sourceId, result) + return { result, moved: true } + } + + if (plan.kind === 'toInline') { + for (const { docId } of plan.alignmentFrameworks) await port.deleteCfPackage(docId) + let result: R + try { + result = await saveFramework({ + ...cfPackage, + CFAssociations: mergeAssociations(inline, plan.alignmentFrameworks.flatMap(({ pkg }) => pkg.CFAssociations ?? [])), + }) + } catch (err) { + for (const { pkg } of plan.alignmentFrameworks) await port.saveCfPackage(pkg) + throw err + } + await followFork(port, sourceId, result) + return { result, moved: true } + } + + const result = await saveFramework(cfPackage) + await followFork(port, sourceId, result) + return { result, moved: false } +} + +/** After a fork, the alignment frameworks still pointing at the framework's old identifiers are updated to its new ones. */ +async function followFork(port: AlignmentStoragePort, oldSourceId: string, result: SaveResult): Promise { + const { forkedIdentifiers } = result + if (!forkedIdentifiers) return + for (const { docId } of await ownedAlignmentFrameworks(port, oldSourceId)) { + const remapped = remapAlignmentFramework(await port.getCfPackage(docId), forkedIdentifiers) + if (remapped) await port.saveCfPackage(remapped) + } +} + +async function ownedAlignmentFrameworks(port: AlignmentStoragePort, sourceId: string): Promise> { + const docs = await port.listAlignmentFrameworks(sourceId) + return docs.flatMap((doc) => { + const ext = doc.extensions?.['ext:opencase'] as AlignmentFrameworkExtensions | undefined + const targetId = alignmentFrameworkTargetId(ext) + return alignmentFrameworkSourceId(ext) === sourceId && targetId ? [{ docId: doc.sourcedId, targetId }] : [] + }) +} diff --git a/apps/editor/src/domain/case/types.ts b/apps/editor/src/domain/case/types.ts index f9d24a6..418628e 100644 --- a/apps/editor/src/domain/case/types.ts +++ b/apps/editor/src/domain/case/types.ts @@ -1,3 +1,5 @@ +import type { AlignmentStorage } from '@/domain/framework/model/types' + /** * CASE 1.1 TypeScript DTOs (JSON shapes) * Mirrors `docs/editor-types.md`. @@ -74,6 +76,12 @@ export interface CFDocument { */ publicAccess?: boolean + /** + * Editor-only: where the framework stores its associations to other frameworks. + * Persisted in `ext:opencase.alignmentStorage`. Absent means inline. + */ + alignmentStorage?: AlignmentStorage + lastChangeDateTime: string /** Link back to containing package */ diff --git a/apps/editor/src/domain/framework/alignmentFrameworks.test.ts b/apps/editor/src/domain/framework/alignmentFrameworks.test.ts new file mode 100644 index 0000000..278aae8 --- /dev/null +++ b/apps/editor/src/domain/framework/alignmentFrameworks.test.ts @@ -0,0 +1,115 @@ +import { describe, it, expect } from 'vitest' +import { + alignmentFrameworkSourceId, + alignmentFrameworkTargetId, + buildAlignmentFrameworkPackage, + mergeAssociations, + remapAlignmentFramework, +} from './alignmentFrameworks' +import type { CFAssociation } from '@/domain/case/types' + +const ref = (identifier: string) => ({ identifier, uri: `/ims/case/v1p1/CFDocuments/${identifier}` }) + +describe('alignmentFrameworkSourceId', () => { + it('uses alignmentSource when present', () => { + expect(alignmentFrameworkSourceId({ + alignmentSource: ref('doc-b'), + alignmentParticipants: [ref('doc-a'), ref('doc-b')], + })).toBe('doc-b') + }) + + it('falls back to the first participant for alignment frameworks saved without alignmentSource', () => { + expect(alignmentFrameworkSourceId({ alignmentParticipants: [ref('doc-a'), ref('doc-b')] })).toBe('doc-a') + }) + + it('is undefined when there is nothing to go on', () => { + expect(alignmentFrameworkSourceId(undefined)).toBeUndefined() + expect(alignmentFrameworkSourceId({ alignmentParticipants: [] })).toBeUndefined() + }) +}) + +describe('alignmentFrameworkTargetId', () => { + it('is the participant other than the source', () => { + expect(alignmentFrameworkTargetId({ + alignmentSource: ref('doc-a'), + alignmentParticipants: [ref('doc-a'), ref('doc-b')], + })).toBe('doc-b') + }) + + it('works out the target of a legacy alignment framework from participant order', () => { + expect(alignmentFrameworkTargetId({ alignmentParticipants: [ref('doc-a'), ref('doc-b')] })).toBe('doc-b') + }) + + it('is the source itself for a self alignment', () => { + expect(alignmentFrameworkTargetId({ alignmentParticipants: [ref('doc-a'), ref('doc-a')] })).toBe('doc-a') + }) + + it('is undefined when the source is unknown', () => { + expect(alignmentFrameworkTargetId({ alignmentParticipants: [{ uri: '/x' }] })).toBeUndefined() + }) +}) + +const assoc = (identifier: string, from: string, to: string): CFAssociation => ({ + identifier, + associationType: 'isRelatedTo', + uri: `/ims/case/v1p1/CFAssociations/${identifier}`, + originNodeURI: { title: from, identifier: from, uri: `/ims/case/v1p1/CFItems/${from}` }, + destinationNodeURI: { title: to, identifier: to, uri: `/ims/case/v1p1/CFItems/${to}` }, + lastChangeDateTime: '2026-10-30T00:00:00Z', +}) + +describe('buildAlignmentFrameworkPackage', () => { + const build = (targetId: string) => buildAlignmentFrameworkPackage({ + identifier: 'af-1', + source: { identifier: 'doc-a', title: 'A' }, + target: { identifier: targetId, title: targetId === 'doc-a' ? 'A' : 'B' }, + creator: 'Me', + associations: [assoc('x', 'a-1', 'b-1')], + now: '2026-10-30T00:00:00Z', + }) + + it('is owned by the source and names both frameworks as participants', () => { + const pkg = build('doc-b') + expect(pkg.CFDocument.title).toBe('Alignment: A → B') + expect(alignmentFrameworkSourceId(pkg.CFDocument.extensions['ext:opencase'])).toBe('doc-a') + expect(alignmentFrameworkTargetId(pkg.CFDocument.extensions['ext:opencase'])).toBe('doc-b') + expect(pkg.CFAssociations.map((a) => a.identifier)).toEqual(['x']) + }) + + it('titles a self alignment as internal', () => { + expect(build('doc-a').CFDocument.title).toBe('Internal Alignment: A') + }) +}) + +describe('mergeAssociations', () => { + it('adds only associations not already present, without the package they came from', () => { + const moved = { ...assoc('y', 'a-2', 'b-2'), CFDocumentURI: { title: 'B', identifier: 'af-1', uri: '/x' } } + const merged = mergeAssociations([assoc('x', 'a-1', 'b-1')], [assoc('x', 'a-1', 'b-1'), moved]) + expect(merged.map((a) => a.identifier)).toEqual(['x', 'y']) + expect(merged[1].CFDocumentURI).toBeUndefined() + }) +}) + +describe('remapAlignmentFramework', () => { + const pkg = () => buildAlignmentFrameworkPackage({ + identifier: 'af-1', + source: { identifier: 'doc-a', title: 'A' }, + target: { identifier: 'doc-b', title: 'B' }, + creator: 'Me', + associations: [assoc('x', 'a-1', 'b-1')], + now: '2026-10-30T00:00:00Z', + }) + + it('follows the source framework to its forked identifiers, leaving the target alone', () => { + const remapped = remapAlignmentFramework(pkg(), { 'doc-a': 'doc-a2', 'a-1': 'a-1x' })! + const ext = remapped.CFDocument.extensions['ext:opencase'] + expect(ext.alignmentSource).toEqual({ identifier: 'doc-a2', uri: '/ims/case/v1p1/CFDocuments/doc-a2' }) + expect(ext.alignmentParticipants?.map((p) => p.identifier)).toEqual(['doc-a2', 'doc-b']) + expect(remapped.CFAssociations[0].originNodeURI).toEqual({ title: 'a-1', identifier: 'a-1x', uri: '/ims/case/v1p1/CFItems/a-1x' }) + expect(remapped.CFAssociations[0].destinationNodeURI.identifier).toBe('b-1') + }) + + it('is undefined when nothing refers to the forked framework', () => { + expect(remapAlignmentFramework(pkg(), { 'doc-z': 'doc-z2' })).toBeUndefined() + }) +}) diff --git a/apps/editor/src/domain/framework/alignmentFrameworks.ts b/apps/editor/src/domain/framework/alignmentFrameworks.ts new file mode 100644 index 0000000..848472f --- /dev/null +++ b/apps/editor/src/domain/framework/alignmentFrameworks.ts @@ -0,0 +1,140 @@ +import type { CFAssociation } from '@/domain/case/types' + +/** A framework referenced from an alignment framework's `ext:opencase` extension. */ +export type AlignmentFrameworkRef = { identifier?: string; uri: string } + +/** The `ext:opencase` fields of an alignment framework's CFDocument. */ +export type AlignmentFrameworkExtensions = { + /** The framework whose associations this alignment framework holds — its owner. */ + alignmentSource?: AlignmentFrameworkRef + alignmentParticipants?: AlignmentFrameworkRef[] +} + +/** + * The framework that owns an alignment framework: the one its associations + * originate from. Alignment frameworks saved before `alignmentSource` existed + * always listed the source as their first participant. + */ +export function alignmentFrameworkSourceId(ext: AlignmentFrameworkExtensions | undefined): string | undefined { + return ext?.alignmentSource?.identifier ?? ext?.alignmentParticipants?.[0]?.identifier +} + +/** + * The framework an alignment framework's associations point into: the + * participant other than the source, or the source itself for a self + * (intra-framework) alignment. + */ +export function alignmentFrameworkTargetId(ext: AlignmentFrameworkExtensions | undefined): string | undefined { + const sourceId = alignmentFrameworkSourceId(ext) + if (!sourceId) return undefined + const other = ext?.alignmentParticipants?.find((p) => p.identifier && p.identifier !== sourceId) + return other?.identifier ?? sourceId +} + +/** An alignment framework's package, as saved: no items, only the source framework's associations. */ +export type AlignmentFrameworkPackage = { + CFDocument: { + identifier: string + uri: string + title: string + creator: string + frameworkType: 'Alignment' + lastChangeDateTime: string + extensions: { 'ext:opencase': AlignmentFrameworkExtensions } + } + CFItems: [] + CFAssociations: CFAssociation[] +} + +type FrameworkRef = { identifier: string; title: string } + +const documentUri = (identifier: string) => `/ims/case/v1p1/CFDocuments/${identifier}` +const itemUri = (identifier: string) => `/ims/case/v1p1/CFItems/${identifier}` + +/** The alignment framework owned by `source` that holds its associations into `target`. */ +export function buildAlignmentFrameworkPackage(params: { + identifier: string + source: FrameworkRef + target: FrameworkRef + creator: string + associations: CFAssociation[] + now: string +}): AlignmentFrameworkPackage { + const { identifier, source, target, creator, associations, now } = params + const sourceRef = { identifier: source.identifier, uri: documentUri(source.identifier) } + return { + CFDocument: { + identifier, + uri: documentUri(identifier), + title: source.identifier === target.identifier + ? `Internal Alignment: ${source.title}` + : `Alignment: ${source.title} → ${target.title}`, + creator, + frameworkType: 'Alignment', + lastChangeDateTime: now, + extensions: { + 'ext:opencase': { + alignmentSource: sourceRef, + alignmentParticipants: [sourceRef, { identifier: target.identifier, uri: documentUri(target.identifier) }], + }, + }, + }, + CFItems: [], + CFAssociations: associations, + } +} + +/** + * `existing` plus each of `added` not already among them. `CFDocumentURI` is + * dropped from the added ones: it names the package they came from. + */ +export function mergeAssociations(existing: CFAssociation[], added: CFAssociation[]): CFAssociation[] { + const ids = new Set(existing.map((a) => a.identifier)) + const fresh = added + .filter((a) => !ids.has(a.identifier)) + .map(({ CFDocumentURI: _from, ...a }) => a) + return fresh.length > 0 ? [...existing, ...fresh] : existing +} + +/** + * An alignment framework updated for its source framework's fork, which gave + * the source document and its items new identifiers (`forkedIdentifiers`, + * old → new). Undefined when nothing in it refers to them. + */ +export function remapAlignmentFramework

}; CFAssociations?: CFAssociation[] }>( + pkg: P, + forkedIdentifiers: Record, +): P | undefined { + let changed = false + const remapFramework = (ref: AlignmentFrameworkRef): AlignmentFrameworkRef => { + const newId = ref.identifier ? forkedIdentifiers[ref.identifier] : undefined + if (!newId) return ref + changed = true + return { identifier: newId, uri: documentUri(newId) } + } + const remapNode = (ref: CFAssociation['originNodeURI']): CFAssociation['originNodeURI'] => { + const newId = ref.identifier ? forkedIdentifiers[ref.identifier] : undefined + if (!newId) return ref + changed = true + return { ...ref, identifier: newId, uri: /\/CFDocuments\//.test(ref.uri) ? documentUri(newId) : itemUri(newId) } + } + + const ext = pkg.CFDocument.extensions?.['ext:opencase'] as AlignmentFrameworkExtensions | undefined + const remappedExt: AlignmentFrameworkExtensions = { + ...ext, + ...(ext?.alignmentSource ? { alignmentSource: remapFramework(ext.alignmentSource) } : {}), + ...(ext?.alignmentParticipants ? { alignmentParticipants: ext.alignmentParticipants.map(remapFramework) } : {}), + } + const associations = pkg.CFAssociations?.map((a) => ({ + ...a, + originNodeURI: remapNode(a.originNodeURI), + destinationNodeURI: remapNode(a.destinationNodeURI), + })) + + if (!changed) return undefined + return { + ...pkg, + CFDocument: { ...pkg.CFDocument, extensions: { ...pkg.CFDocument.extensions, 'ext:opencase': remappedExt } }, + ...(associations ? { CFAssociations: associations } : {}), + } +} diff --git a/apps/editor/src/domain/framework/externalAlignments.test.ts b/apps/editor/src/domain/framework/externalAlignments.test.ts new file mode 100644 index 0000000..fbb1ac4 --- /dev/null +++ b/apps/editor/src/domain/framework/externalAlignments.test.ts @@ -0,0 +1,174 @@ +import { describe, it, expect } from 'vitest' +import { + externalAlignmentsOf, + groupExternalAlignments, + hostedLookupCandidates, + isRemoteTargetId, + type ExternalAlignment, +} from './externalAlignments' +import type { Association, Framework, Item } from '@/domain/framework/model/types' +import type { AssociationId, FrameworkId, ItemId } from '@/domain/shared/types' + +const item = (id: string): Item => ({ id: id as ItemId, statement: id, metadata: { caseUri: `https://case.example/CFItems/${id}` } }) +const assoc = (id: string, from: string, to: string, associationType: Association['associationType'] = 'isRelatedTo', metadata?: Association['metadata']): Association => ({ + id: id as AssociationId, + fromItemId: from as ItemId, + toItemId: to as ItemId, + associationType, + metadata, +}) +const framework = (items: Item[], associations: Association[]): Framework => ({ + id: 'doc-1' as FrameworkId, + metadata: { title: 'Doc' }, + items: new Map(items.map((i) => [i.id, i])), + associations: new Map(associations.map((a) => [a.id, a])), + status: 'Published', +}) + +const alignment = (id: string, toItemId: string, extra: Partial = {}): ExternalAlignment => ({ + id, + fromItemId: 'item-1', + toItemId, + associationType: 'isRelatedTo', + originUri: 'https://case.example/CFItems/item-1', + destinationUri: `https://case.example/CFItems/${toItemId}`, + ...extra, +}) + +describe('externalAlignmentsOf', () => { + const items = [item('item-1'), item('item-2')] + + it('returns non-hierarchical associations from an item to a node outside the framework', () => { + const fw = framework(items, [ + assoc('a-1', 'item-1', 'other-item', 'ext:supports' as Association['associationType'], { + destinationUri: 'https://case.example/CFItems/other-item', + destinationTitle: 'Other item', + }), + ]) + + expect(externalAlignmentsOf(fw)).toEqual([{ + id: 'a-1', + fromItemId: 'item-1', + toItemId: 'other-item', + associationType: 'ext:supports', + originUri: 'https://case.example/CFItems/item-1', + destinationUri: 'https://case.example/CFItems/other-item', + destinationTitle: 'Other item', + destinationTargetType: undefined, + }]) + }) + + it('keeps non-CASE targets', () => { + const fw = framework(items, [ + assoc('a-1', 'item-1', 'goal-4.2.15', 'isRelatedTo', { + destinationUri: 'https://goals.example.org/goals/LO_V2_0?x=1', + destinationTargetType: 'ext:curriculumGoals', + }), + ]) + + expect(externalAlignmentsOf(fw).map((a) => a.toItemId)).toEqual(['goal-4.2.15']) + }) + + it('skips hierarchical associations, even to outside nodes', () => { + const fw = framework(items, [ + assoc('child', 'item-2', 'item-1', 'isChildOf'), + assoc('part', 'item-1', 'outside', 'isPartOf'), + ]) + + expect(externalAlignmentsOf(fw)).toEqual([]) + }) + + it('skips associations within the framework', () => { + const fw = framework(items, [assoc('a-1', 'item-1', 'item-2')]) + + expect(externalAlignmentsOf(fw)).toEqual([]) + }) + + it('skips associations whose origin is not one of the framework items', () => { + const fw = framework(items, [assoc('a-1', 'outside', 'item-1')]) + + expect(externalAlignmentsOf(fw)).toEqual([]) + }) + + it('skips links to CFDocuments — its own or another framework\'s', () => { + const fw = framework(items, [ + assoc('own', 'item-1', 'doc-1', 'exactMatchOf'), + assoc('other', 'item-1', 'doc-2', 'exactMatchOf', { destinationUri: 'https://case.example/ims/case/v1p1/CFDocuments/doc-2' }), + ]) + + expect(externalAlignmentsOf(fw)).toEqual([]) + }) + + it('falls back to the item URI and a URN when the association carries no URIs', () => { + const fw = framework(items, [assoc('a-1', 'item-1', 'other-item')]) + + const [a] = externalAlignmentsOf(fw) + expect(a.originUri).toBe('https://case.example/CFItems/item-1') + expect(a.destinationUri).toBe('urn:case:item:other-item') + }) +}) + +describe('hostedLookupCandidates', () => { + it('returns each CASE destination once and leaves out non-CASE targets', () => { + const alignments = [ + alignment('a-1', 'x'), + alignment('a-2', 'x'), + alignment('a-3', 'y', { destinationTargetType: 'CASE' }), + alignment('a-4', 'goal-3', { destinationTargetType: 'ext:curriculumGoals' }), + ] + + expect(hostedLookupCandidates(alignments)).toEqual(['x', 'y']) + }) +}) + +describe('groupExternalAlignments', () => { + it('groups hosted destinations by the framework that holds them', () => { + const targets = groupExternalAlignments( + [alignment('a-1', 'x'), alignment('a-2', 'y'), alignment('a-3', 'z')], + { + x: { identifier: 'doc-b', title: 'Framework B' }, + y: { identifier: 'doc-b', title: 'Framework B' }, + z: { identifier: 'doc-a', title: 'Framework A' }, + }, + ) + + expect(targets.map((t) => [t.kind, t.id, t.title, t.alignments.map((a) => a.id)])).toEqual([ + ['hosted', 'doc-a', 'Framework A', ['a-3']], + ['hosted', 'doc-b', 'Framework B', ['a-1', 'a-2']], + ]) + }) + + it('groups unresolved destinations as remote, by URI host, after the hosted ones', () => { + const targets = groupExternalAlignments( + [ + alignment('a-1', 'goal-1', { destinationUri: 'https://goals.example.org/goals/LO_V2_0?a=1', destinationTargetType: 'ext:curriculumGoals' }), + alignment('a-2', 'sibling', { destinationUri: 'https://case.example.org/ims/case/v1p1/CFItems/sibling' }), + alignment('a-3', 'hosted'), + alignment('a-4', 'goal-2', { destinationUri: 'https://goals.example.org/goals/KO_V2_0?b=2', destinationTargetType: 'ext:curriculumGoals' }), + ], + { hosted: { identifier: 'doc-b', title: 'Framework B' } }, + ) + + expect(targets.map((t) => [t.kind, t.id, t.alignments.map((a) => a.id)])).toEqual([ + ['hosted', 'doc-b', ['a-3']], + ['remote', 'remote:case.example.org', ['a-2']], + ['remote', 'remote:goals.example.org', ['a-1', 'a-4']], + ]) + expect(targets.slice(1).every((t) => isRemoteTargetId(t.id))).toBe(true) + }) + + it('treats a non-CASE target as remote even if the server knows its identifier', () => { + const targets = groupExternalAlignments( + [alignment('a-1', 'x', { destinationUri: 'https://elsewhere.example/x', destinationTargetType: 'ext:other' })], + { x: { identifier: 'doc-b', title: 'Framework B' } }, + ) + + expect(targets.map((t) => [t.kind, t.id])).toEqual([['remote', 'remote:elsewhere.example']]) + }) + + it('puts destinations without a web URI under one catch-all source', () => { + const targets = groupExternalAlignments([alignment('a-1', 'x', { destinationUri: 'urn:case:item:x' })], {}) + + expect(targets.map((t) => [t.id, t.title])).toEqual([['remote:Other sources', 'Other sources']]) + }) +}) diff --git a/apps/editor/src/domain/framework/externalAlignments.ts b/apps/editor/src/domain/framework/externalAlignments.ts new file mode 100644 index 0000000..93308d3 --- /dev/null +++ b/apps/editor/src/domain/framework/externalAlignments.ts @@ -0,0 +1,127 @@ +import type { Framework } from '@/domain/framework/model/types' + +/** + * A non-hierarchical association stored in this framework's own CFPackage + * (inline) whose destination is outside the framework — in another framework + * hosted on this server, or somewhere else entirely (remote). + */ +export type ExternalAlignment = { + id: string + fromItemId: string + toItemId: string + associationType: string + originUri: string + destinationUri: string + destinationTitle?: string + destinationTargetType?: string +} + +/** The framework (hosted) or source (remote) an external alignment points into. */ +export type ExternalAlignmentTarget = + | { kind: 'hosted'; id: string; title: string; alignments: ExternalAlignment[] } + | { kind: 'remote'; id: string; title: string; alignments: ExternalAlignment[] } + +/** Hosted document that holds an item, as resolved by the server. */ +export type HostedDocumentRef = { identifier: string; title: string } + +const HIERARCHICAL_TYPES = new Set(['isChildOf', 'isPartOf']) + +/** Prefix that marks a remote target id, so it can't collide with a hosted framework id. */ +export const REMOTE_TARGET_PREFIX = 'remote:' + +export function isRemoteTargetId(id: string): boolean { + return id.startsWith(REMOTE_TARGET_PREFIX) +} + +/** + * The framework's inline external alignments: non-hierarchical associations + * from one of its items to a node that isn't one of its items. + * + * Links to a CFDocument (this framework's own, or another's — e.g. an overview + * framework's `exactMatchOf` to each subject framework) aren't item-to-item + * alignments and are left out. + */ +export function externalAlignmentsOf(framework: Framework): ExternalAlignment[] { + const frameworkId = String(framework.id) + const result: ExternalAlignment[] = [] + for (const a of framework.associations.values()) { + if (HIERARCHICAL_TYPES.has(a.associationType)) continue + if (!framework.items.has(a.fromItemId)) continue + if (framework.items.has(a.toItemId)) continue + const toItemId = String(a.toItemId) + if (toItemId === frameworkId) continue + + const md = a.metadata ?? {} + const destinationUri = md.destinationUri ?? `urn:case:item:${toItemId}` + if (/\/CFDocuments\//.test(destinationUri)) continue + + const fromItemId = String(a.fromItemId) + const itemUri = (framework.items.get(a.fromItemId)?.metadata as Record | undefined)?.caseUri + result.push({ + id: String(a.id), + fromItemId, + toItemId, + associationType: a.associationType, + originUri: md.originUri ?? (typeof itemUri === 'string' ? itemUri : `urn:case:item:${fromItemId}`), + destinationUri, + destinationTitle: md.destinationTitle, + destinationTargetType: md.destinationTargetType, + }) + } + return result +} + +/** Destination identifiers worth asking the server about — non-CASE targets can never be hosted. */ +export function hostedLookupCandidates(alignments: ExternalAlignment[]): string[] { + return [...new Set(alignments.filter((a) => !isNonCaseTarget(a)).map((a) => a.toItemId))] +} + +/** + * Groups external alignments by where their destination lives: each hosted + * framework becomes one target, and remote destinations are grouped by the + * host of their URI (e.g. `goals.example.org`). + * + * `hostedDocuments` is the server's answer for which framework holds each + * destination; anything it doesn't cover — and every non-CASE target — is + * remote. Hosted targets come first, then remote ones, each sorted by title. + */ +export function groupExternalAlignments( + alignments: ExternalAlignment[], + hostedDocuments: Record, +): ExternalAlignmentTarget[] { + const hosted = new Map() + const remote = new Map() + + for (const a of alignments) { + const doc = isNonCaseTarget(a) ? undefined : hostedDocuments[a.toItemId] + if (doc) { + const target = hosted.get(doc.identifier) ?? { kind: 'hosted', id: doc.identifier, title: doc.title, alignments: [] } + target.alignments.push(a) + hosted.set(doc.identifier, target) + } else { + const source = remoteSourceOf(a.destinationUri) + const id = `${REMOTE_TARGET_PREFIX}${source}` + const target = remote.get(id) ?? { kind: 'remote', id, title: source, alignments: [] } + target.alignments.push(a) + remote.set(id, target) + } + } + + const byTitle = (x: ExternalAlignmentTarget, y: ExternalAlignmentTarget) => x.title.localeCompare(y.title) + return [...[...hosted.values()].sort(byTitle), ...[...remote.values()].sort(byTitle)] +} + +function isNonCaseTarget(a: ExternalAlignment): boolean { + return a.destinationTargetType !== undefined && a.destinationTargetType !== 'CASE' +} + +const UNKNOWN_SOURCE = 'Other sources' + +function remoteSourceOf(uri: string): string { + try { + const { protocol, hostname } = new URL(uri) + return (protocol === 'http:' || protocol === 'https:') && hostname ? hostname : UNKNOWN_SOURCE + } catch { + return UNKNOWN_SOURCE + } +} diff --git a/apps/editor/src/domain/framework/hasFrameworkDataChanged.test.ts b/apps/editor/src/domain/framework/hasFrameworkDataChanged.test.ts index 2aff875..174e6db 100644 --- a/apps/editor/src/domain/framework/hasFrameworkDataChanged.test.ts +++ b/apps/editor/src/domain/framework/hasFrameworkDataChanged.test.ts @@ -55,6 +55,21 @@ describe('hasFrameworkDataChanged', () => { expect(hasFrameworkDataChanged(a, b)).toBe(false) }) + it('ignores changing where associations to other frameworks are stored', () => { + const a = baseFramework() + const b = clone(a) + b.metadata = { ...b.metadata, alignmentStorage: 'alignmentFramework' } + expect(hasFrameworkDataChanged(a, b)).toBe(false) + }) + + it('ignores the timestamp stamped when a setting is changed', () => { + const a = baseFramework() + a.metadata = { ...a.metadata, lastChangeDateTime: '2026-10-28T13:42:05.098Z' } + const b = clone(a) + b.metadata = { ...b.metadata, publicAccess: true, lastChangeDateTime: '2026-10-30T14:17:21.646Z' } + expect(hasFrameworkDataChanged(a, b)).toBe(false) + }) + it('ignores canvas-only changes nested under metadata["ext:opencase"]', () => { const a = baseFramework() const b = clone(a) diff --git a/apps/editor/src/domain/framework/hasFrameworkDataChanged.ts b/apps/editor/src/domain/framework/hasFrameworkDataChanged.ts index 47459b4..bd35ace 100644 --- a/apps/editor/src/domain/framework/hasFrameworkDataChanged.ts +++ b/apps/editor/src/domain/framework/hasFrameworkDataChanged.ts @@ -56,9 +56,10 @@ function sortedNormalizedEntries(map: Map, normalize: */ function comparableDocumentMetadata(metadata: Framework['metadata']): Framework['metadata'] { if (!metadata) return {} - // Public read access is an OpenCASE server setting, not CASE content. - // Toggling it must not fork a mirrored framework. - const { publicAccess: _publicAccess, ...rest } = metadata + // Public read access and association storage are OpenCASE settings, not CASE + // content. Changing them must not fork a mirrored framework. Nor does the + // timestamp stamped on any edit to the document — the server ignores it too. + const { publicAccess: _publicAccess, alignmentStorage: _alignmentStorage, lastChangeDateTime: _lastChangeDateTime, ...rest } = metadata return rest } diff --git a/apps/editor/src/domain/framework/inlineAlignmentEdits.test.ts b/apps/editor/src/domain/framework/inlineAlignmentEdits.test.ts new file mode 100644 index 0000000..a88d3ea --- /dev/null +++ b/apps/editor/src/domain/framework/inlineAlignmentEdits.test.ts @@ -0,0 +1,70 @@ +import { describe, it, expect } from 'vitest' +import { + NO_INLINE_ALIGNMENT_EDITS, + addInlineAlignment, + applyInlineAlignmentEdits, + changeInlineAlignmentType, + hasInlineAlignmentEdits, + removeInlineAlignment, +} from './inlineAlignmentEdits' +import type { Association, Framework, Item } from '@/domain/framework/model/types' +import type { AssociationId, FrameworkId, ItemId } from '@/domain/shared/types' + +const item = (id: string): Item => ({ id: id as ItemId, statement: id }) +const assoc = (id: string, to: string, associationType: Association['associationType'] = 'isRelatedTo'): Association => ({ + id: id as AssociationId, + fromItemId: 'item-1' as ItemId, + toItemId: to as ItemId, + associationType, +}) +const framework = (associations: Association[]): Framework => ({ + id: 'doc-1' as FrameworkId, + metadata: { title: 'Doc' }, + items: new Map([item('item-1')].map((i) => [i.id, i])), + associations: new Map(associations.map((a) => [a.id, a])), + status: 'Published', +}) + +const existing = assoc('existing', 'other-item') + +describe('inline alignment edits', () => { + it('leaves the framework untouched when there are no edits', () => { + const fw = framework([existing]) + expect(applyInlineAlignmentEdits(fw, NO_INLINE_ALIGNMENT_EDITS)).toBe(fw) + expect(hasInlineAlignmentEdits(NO_INLINE_ALIGNMENT_EDITS)).toBe(false) + }) + + it('adds, removes and retypes associations', () => { + let edits = addInlineAlignment(NO_INLINE_ALIGNMENT_EDITS, assoc('new', 'another-item')) + edits = changeInlineAlignmentType(edits, 'existing', 'exactMatchOf') + const fw = applyInlineAlignmentEdits(framework([existing, assoc('gone', 'x')]), removeInlineAlignment(edits, 'gone')) + + expect([...fw.associations.keys()].sort()).toEqual(['existing', 'new']) + expect(fw.associations.get('existing' as AssociationId)?.associationType).toBe('exactMatchOf') + }) + + it('forgets an association added and then removed in the same session', () => { + const edits = removeInlineAlignment(addInlineAlignment(NO_INLINE_ALIGNMENT_EDITS, assoc('new', 'x')), 'new') + expect(hasInlineAlignmentEdits(edits)).toBe(false) + }) + + it('retypes an association added in this session in place', () => { + const edits = changeInlineAlignmentType(addInlineAlignment(NO_INLINE_ALIGNMENT_EDITS, assoc('new', 'x')), 'new', 'isPeerOf') + expect(edits.added[0].associationType).toBe('isPeerOf') + expect(edits.typeChanges).toEqual({}) + }) + + it('drops a pending type change when the association is removed', () => { + const edits = removeInlineAlignment(changeInlineAlignmentType(NO_INLINE_ALIGNMENT_EDITS, 'existing', 'isPeerOf'), 'existing') + expect(edits).toEqual({ added: [], removedIds: ['existing'], typeChanges: {} }) + }) + + it('is idempotent: applying the edits to an already-edited framework changes nothing', () => { + let edits = addInlineAlignment(NO_INLINE_ALIGNMENT_EDITS, assoc('new', 'x')) + edits = changeInlineAlignmentType(edits, 'existing', 'exactMatchOf') + edits = removeInlineAlignment(edits, 'gone') + const once = applyInlineAlignmentEdits(framework([existing, assoc('gone', 'y')]), edits) + const twice = applyInlineAlignmentEdits(once, edits) + expect(twice.associations).toEqual(once.associations) + }) +}) diff --git a/apps/editor/src/domain/framework/inlineAlignmentEdits.ts b/apps/editor/src/domain/framework/inlineAlignmentEdits.ts new file mode 100644 index 0000000..76ae49c --- /dev/null +++ b/apps/editor/src/domain/framework/inlineAlignmentEdits.ts @@ -0,0 +1,60 @@ +import type { Association, AssociationType, Framework } from '@/domain/framework/model/types' + +/** + * Edits to a framework's inline associations to other frameworks, made in the + * Tree View. These associations aren't canvas edges, so they never enter the + * editor graph: the edits are kept alongside it and applied to the framework + * when it's saved. + * + * Every edit is idempotent, so applying them again to a framework that already + * has them (e.g. after a save) changes nothing. + */ +export type InlineAlignmentEdits = { + added: Association[] + removedIds: string[] + /** association id → new associationType */ + typeChanges: Record +} + +export const NO_INLINE_ALIGNMENT_EDITS: InlineAlignmentEdits = { added: [], removedIds: [], typeChanges: {} } + +export function addInlineAlignment(edits: InlineAlignmentEdits, association: Association): InlineAlignmentEdits { + return { ...edits, added: [...edits.added.filter((a) => a.id !== association.id), association] } +} + +export function removeInlineAlignment(edits: InlineAlignmentEdits, id: string): InlineAlignmentEdits { + const wasAdded = edits.added.some((a) => String(a.id) === id) + const { [id]: _dropped, ...typeChanges } = edits.typeChanges + return { + added: wasAdded ? edits.added.filter((a) => String(a.id) !== id) : edits.added, + // An association added in this session was never saved, so dropping it is enough. + removedIds: wasAdded || edits.removedIds.includes(id) ? edits.removedIds : [...edits.removedIds, id], + typeChanges, + } +} + +export function changeInlineAlignmentType(edits: InlineAlignmentEdits, id: string, associationType: string): InlineAlignmentEdits { + if (edits.added.some((a) => String(a.id) === id)) { + return { + ...edits, + added: edits.added.map((a) => (String(a.id) === id ? { ...a, associationType: associationType as AssociationType } : a)), + } + } + return { ...edits, typeChanges: { ...edits.typeChanges, [id]: associationType } } +} + +export function hasInlineAlignmentEdits(edits: InlineAlignmentEdits): boolean { + return edits.added.length > 0 || edits.removedIds.length > 0 || Object.keys(edits.typeChanges).length > 0 +} + +export function applyInlineAlignmentEdits(framework: Framework, edits: InlineAlignmentEdits): Framework { + if (!hasInlineAlignmentEdits(edits)) return framework + const associations = new Map(framework.associations) + for (const id of edits.removedIds) associations.delete(id as Association['id']) + for (const [id, associationType] of Object.entries(edits.typeChanges)) { + const existing = associations.get(id as Association['id']) + if (existing) associations.set(existing.id, { ...existing, associationType: associationType as AssociationType }) + } + for (const a of edits.added) associations.set(a.id, a) + return { ...framework, associations } +} diff --git a/apps/editor/src/domain/framework/model/types.ts b/apps/editor/src/domain/framework/model/types.ts index c545af0..69bd085 100644 --- a/apps/editor/src/domain/framework/model/types.ts +++ b/apps/editor/src/domain/framework/model/types.ts @@ -13,6 +13,12 @@ export type AssociationType = | 'precedes' | 'isReplacedBy' +/** + * Where a framework stores its associations to other frameworks: in its own + * CFPackage (`inline`), or in alignment frameworks it owns (`alignmentFramework`). + */ +export type AlignmentStorage = 'inline' | 'alignmentFramework' + export type FrameworkMetadata = { title?: string description?: string @@ -45,6 +51,11 @@ export type FrameworkMetadata = { * Persisted as `ext:opencase.publicAccess`. Absent means sign-in is required. */ publicAccess?: boolean + /** + * Where the framework stores its associations to other frameworks. + * Persisted as `ext:opencase.alignmentStorage`. Absent means inline. + */ + alignmentStorage?: AlignmentStorage /** * Non-OpenCASE CASE extensions on the CFDocument, preserved verbatim on save. * Never contains `ext:opencase`, which is regenerated from editor state. diff --git a/apps/editor/src/infrastructure/caseApi/CaseApiClient.ts b/apps/editor/src/infrastructure/caseApi/CaseApiClient.ts index a895c31..2f186a1 100644 --- a/apps/editor/src/infrastructure/caseApi/CaseApiClient.ts +++ b/apps/editor/src/infrastructure/caseApi/CaseApiClient.ts @@ -16,6 +16,8 @@ export type OpenCaseListExtensions = { sourcePackageURI?: string isModifiedFromSource?: boolean alignmentParticipants?: Array<{ identifier?: string; uri: string }> + /** Alignment frameworks only: the framework whose associations it holds (its owner). */ + alignmentSource?: { identifier?: string; uri: string } /** When true, the CASE API serves this framework without authentication. */ publicAccess?: boolean /** Server-level archive flag — independent of CASE adoptionStatus. */ @@ -167,18 +169,27 @@ export class CaseApiClient { tenantId: string cfPackage: unknown // OpenCaseCFPackage format caseVersion?: 'v1p0' | 'v1p1' - }): Promise<{ docId: string; version: string; forked?: boolean; isModifiedFromSource?: boolean; sourcePackageURI?: string }> { + }): Promise<{ + docId: string + version: string + forked?: boolean + /** On a fork: each old document and item identifier → its new one. */ + forkedIdentifiers?: Record + isModifiedFromSource?: boolean + sourcePackageURI?: string + }> { const v = params.caseVersion ?? 'v1p1' const url = `/management/tenants/${encodeURIComponent(params.tenantId)}/ims/case/${v}/CFPackages` const res = (await this._http.post(url, params.cfPackage)) as unknown if (res && typeof res === 'object') { - const obj = res as { docId?: string; version?: string; identifier?: string; forked?: boolean; isModifiedFromSource?: boolean; sourcePackageURI?: string } + const obj = res as { docId?: string; version?: string; identifier?: string; forked?: boolean; forkedIdentifiers?: Record; isModifiedFromSource?: boolean; sourcePackageURI?: string } return { docId: obj.docId ?? obj.identifier ?? '', version: obj.version ?? '', forked: obj.forked, + forkedIdentifiers: obj.forkedIdentifiers, isModifiedFromSource: obj.isModifiedFromSource, sourcePackageURI: obj.sourcePackageURI, } @@ -376,6 +387,26 @@ export class CaseApiClient { return [] } + /** + * Resolve item identifiers to the hosted CFDocuments that hold them. + * Identifiers not on this server (or in an archived framework) are absent from the result. + * + * Uses the management endpoint: + * POST /management/tenants/{tenantId}/ims/case/{version}/CFItems/lookup-documents + */ + async lookupItemDocuments(params: { + tenantId: string + identifiers: string[] + caseVersion?: 'v1p0' | 'v1p1' + }): Promise> { + if (params.identifiers.length === 0) return {} + const v = params.caseVersion ?? 'v1p1' + const url = `/management/tenants/${encodeURIComponent(params.tenantId)}/ims/case/${v}/CFItems/lookup-documents` + const res = (await this._http.post(url, { identifiers: params.identifiers })) as + { documents?: Record } | null + return res?.documents ?? {} + } + // ── API Key Management ────────────────────────────────────────── /** diff --git a/apps/editor/src/ui/editor/EditorCanvas.tsx b/apps/editor/src/ui/editor/EditorCanvas.tsx index e833beb..a3f2ae6 100644 --- a/apps/editor/src/ui/editor/EditorCanvas.tsx +++ b/apps/editor/src/ui/editor/EditorCanvas.tsx @@ -40,6 +40,9 @@ import { frameworkToCfPackage, toOpenCaseFormat } from '@/application/framework/ import type { Framework } from '@/domain/framework/model/types' import { hasFrameworkDataChanged } from '@/domain/framework/hasFrameworkDataChanged' import { withOffCanvasAssociations } from '@/domain/framework/offCanvasAssociations' +import { alignmentMoveCount, type AlignmentStoragePlan } from '@/application/framework/services/AlignmentStorage' +import type { HostedDocumentRef } from '@/domain/framework/externalAlignments' +import { applyInlineAlignmentEdits } from '@/domain/framework/inlineAlignmentEdits' // ── Stable ReactFlow config (module scope — never recreated) ────────────── // @@ -193,7 +196,9 @@ type MirrorStatus = { isModifiedFromSource?: boolean; sourcePackageURI?: string type EditorCanvasProps = { onBack?: () => void - onSaveToServer?: (cfPackage: ReturnType, framework: Framework) => Promise + onSaveToServer?: (cfPackage: ReturnType, framework: Framework, plan?: AlignmentStoragePlan) => Promise + /** Works out which associations a save will move for the framework's alignment storage setting, without saving */ + onPlanAlignmentStorage?: (cfPackage: ReturnType, framework: Framework) => Promise /** Archive the current framework on the server and navigate home */ onArchiveFramework?: () => Promise /** Fetch the published CFPackage from the server (returns CASE JSON with absolute URIs) */ @@ -213,6 +218,8 @@ type EditorCanvasProps = { }> /** Discover all frameworks that already have saved alignment docs with the given source framework */ onDiscoverAlignedTargets?: (sourceId: string) => Promise> + /** Resolve item identifiers to the hosted frameworks that hold them — used by the tree view to place inline external alignments */ + onLookupItemDocuments?: (identifiers: string[]) => Promise> /** Mirror/fork status of the open framework, if it was ever imported */ mirrorStatus?: MirrorStatus /** Read-only cached remote framework (CGE cache) */ @@ -221,6 +228,8 @@ type EditorCanvasProps = { cfDefinitionsExtensions?: CaseExtensions /** The framework as loaded — its associations to nodes outside this framework have no canvas edge, so they're carried through on save */ loadedFramework?: Framework + /** Changes whenever a save moved associations between this framework and its alignment frameworks */ + alignmentsRevision?: number } function RemoteLinkInternalsSync({ @@ -252,7 +261,15 @@ function RemoteLinkInternalsSync({ return null } -export default function EditorCanvas({ onBack, onSaveToServer, onArchiveFramework, onFetchCfPackage, availableFrameworks, serverFrameworks, onLoadTargetFramework, onSaveAlignments, onLoadAlignmentsForTarget, onDiscoverAlignedTargets, mirrorStatus, readOnly, cfDefinitionsExtensions, loadedFramework }: Readonly) { +/** The fork warning's sentence about associations a save will move. */ +function describeAlignmentMove(plan: AlignmentStoragePlan, count: number): string { + const associations = count === 1 ? '1 association' : `${count} associations` + return plan.kind === 'toInline' + ? `${associations} to frameworks on this server will move from alignment frameworks into this framework.` + : `${associations} to frameworks on this server will move from this framework into alignment frameworks.` +} + +export default function EditorCanvas({ onBack, onSaveToServer, onPlanAlignmentStorage, onArchiveFramework, onFetchCfPackage, availableFrameworks, serverFrameworks, onLoadTargetFramework, onSaveAlignments, onLoadAlignmentsForTarget, onDiscoverAlignedTargets, onLookupItemDocuments, mirrorStatus, readOnly, cfDefinitionsExtensions, loadedFramework, alignmentsRevision }: Readonly) { const { status: authStatus, userName, tenantId, signOut, changePassword } = useAuth() const { nodes, @@ -296,6 +313,8 @@ export default function EditorCanvas({ onBack, onSaveToServer, onArchiveFramewor addDetachedItem, addExternalFramework, remoteLinks, + inlineAlignmentEdits, + clearInlineAlignmentEdits, addRemoteLink, removeRemoteLink, updateRemoteLinkType, @@ -361,7 +380,9 @@ export default function EditorCanvas({ onBack, onSaveToServer, onArchiveFramewor const baseline = fromEditorGraph({ graph: { nodes, edges: editorEdges } }).framework baselineFrameworkRef.current = loadedFramework ? withOffCanvasAssociations(loadedFramework, baseline) : baseline } - const pendingSaveRef = useRef<{ openCasePackage: ReturnType; framework: Framework } | null>(null) + const pendingSaveRef = useRef<{ openCasePackage: ReturnType; framework: Framework; plan?: AlignmentStoragePlan } | null>(null) + // What the fork warning says about associations the save will move, if any. + const [forkWarningMove, setForkWarningMove] = useState(null) // Available licenses from context (loaded via App.tsx definitions pipeline) const availableLicenses = cfLicenses @@ -405,8 +426,8 @@ export default function EditorCanvas({ onBack, onSaveToServer, onArchiveFramewor const edgeReconnectSuccessful = useRef(true) // ── Refs for save/export (keeps callbacks stable, avoids re-renders) ── - const graphRef = useRef({ nodes, edges: editorEdges, remoteLinks }) - graphRef.current = { nodes, edges: editorEdges, remoteLinks } + const graphRef = useRef({ nodes, edges: editorEdges, remoteLinks, inlineAlignmentEdits }) + graphRef.current = { nodes, edges: editorEdges, remoteLinks, inlineAlignmentEdits } const saveCtxRef = useRef({ caseVersion, edgeType: settings.edgeType, cfItemTypes, cfSubjects, cfConcepts, cfLicenses, cfAssociationGroupings, cfDefinitionsExtensions, loadedFramework }) saveCtxRef.current = { caseVersion, edgeType: settings.edgeType, cfItemTypes, cfSubjects, cfConcepts, cfLicenses, cfAssociationGroupings, cfDefinitionsExtensions, loadedFramework } @@ -426,15 +447,16 @@ export default function EditorCanvas({ onBack, onSaveToServer, onArchiveFramewor // Actually perform the save (network call). Split out from `handleSave` so // the fork-warning dialog can defer this until the user confirms. - const doSave = useCallback(async (openCasePackage: ReturnType, framework: Framework) => { + const doSave = useCallback(async (openCasePackage: ReturnType, framework: Framework, plan?: AlignmentStoragePlan) => { if (onSaveToServer) { setSaveStatus('saving') setSaveError(null) try { - await onSaveToServer(openCasePackage, framework) + await onSaveToServer(openCasePackage, framework, plan) baselineFrameworkRef.current = framework setSaveStatus('success') clearDirty() + clearInlineAlignmentEdits() setTimeout(() => setSaveStatus('idle'), 2000) } catch (err) { console.error('[Save] Failed to save to server:', err) @@ -448,18 +470,20 @@ export default function EditorCanvas({ onBack, onSaveToServer, onArchiveFramewor } else { setCfPackageDialogOpen(true) } - }, [onSaveToServer, clearDirty]) + }, [onSaveToServer, clearDirty, clearInlineAlignmentEdits]) // Save: Generate CFPackage and POST to server — unless this framework is // still a pristine mirror and the pending edits touch actual framework // data (not just canvas layout), in which case warn first: saving will // fork it into an independent local copy. const handleSave = useCallback(async () => { - const { nodes: n, edges: e, remoteLinks: rl } = graphRef.current + const { nodes: n, edges: e, remoteLinks: rl, inlineAlignmentEdits: inlineEdits } = graphRef.current const ctx = saveCtxRef.current const fromGraph = fromEditorGraph({ graph: { nodes: n, edges: e, remoteLinks: rl } }) const { layout, remoteEditorData } = fromGraph - const framework = ctx.loadedFramework ? withOffCanvasAssociations(ctx.loadedFramework, fromGraph.framework) : fromGraph.framework + const withOffCanvas = ctx.loadedFramework ? withOffCanvasAssociations(ctx.loadedFramework, fromGraph.framework) : fromGraph.framework + // Tree View edits to inline associations to other frameworks live outside the graph. + const framework = applyInlineAlignmentEdits(withOffCanvas, inlineEdits) const cfPackage = frameworkToCfPackage({ framework, layout, caseVersion: ctx.caseVersion, edgeType: ctx.edgeType, @@ -471,18 +495,34 @@ export default function EditorCanvas({ onBack, onSaveToServer, onArchiveFramewor const openCasePackage = toOpenCaseFormat(cfPackage) console.log('[Save] Generated OpenCASE package:', openCasePackage) - const willFork = mirrorStatus?.isModifiedFromSource === false && - baselineFrameworkRef.current !== null && - hasFrameworkDataChanged(baselineFrameworkRef.current, framework) + const baseline = baselineFrameworkRef.current + const isPristineMirror = mirrorStatus?.isModifiedFromSource === false && baseline !== null + const storageChanged = baseline !== null && + (baseline.metadata.alignmentStorage ?? 'inline') !== (framework.metadata.alignmentStorage ?? 'inline') + + // Changing where associations to other frameworks are stored can move some on save, which is a + // data change. Ask the server what would move, so the warning only shows when something does. + let plan: AlignmentStoragePlan | undefined + if (isPristineMirror && storageChanged && onPlanAlignmentStorage) { + try { + plan = await onPlanAlignmentStorage(openCasePackage, framework) + } catch (err) { + setSaveStatus('error') + setSaveError(err instanceof Error ? err.message : 'Failed to save') + return + } + } + const moving = plan ? alignmentMoveCount(plan) : 0 - if (willFork) { - pendingSaveRef.current = { openCasePackage, framework } + if (isPristineMirror && (hasFrameworkDataChanged(baseline, framework) || moving > 0)) { + pendingSaveRef.current = { openCasePackage, framework, plan } + setForkWarningMove(plan && moving > 0 ? describeAlignmentMove(plan, moving) : null) setForkWarningOpen(true) return } - await doSave(openCasePackage, framework) - }, [mirrorStatus, doSave]) + await doSave(openCasePackage, framework, plan) + }, [mirrorStatus, doSave, onPlanAlignmentStorage]) // Compute in-use groupings from actual edges (for filter dropdown). // Pathway highlighting only affects canvas edges, so with the canvas view @@ -1798,6 +1838,9 @@ export default function EditorCanvas({ onBack, onSaveToServer, onArchiveFramewor onSaveAlignments={onSaveAlignments} onLoadAlignmentsForTarget={onLoadAlignmentsForTarget} onDiscoverAlignedTargets={onDiscoverAlignedTargets} + loadedFramework={loadedFramework} + onLookupItemDocuments={onLookupItemDocuments} + alignmentsRevision={alignmentsRevision} /> ) : null} @@ -1947,7 +1990,7 @@ export default function EditorCanvas({ onBack, onSaveToServer, onArchiveFramewor { setForkWarningOpen(false) @@ -1957,7 +2000,7 @@ export default function EditorCanvas({ onBack, onSaveToServer, onArchiveFramewor setForkWarningOpen(false) const pending = pendingSaveRef.current pendingSaveRef.current = null - if (pending) void doSave(pending.openCasePackage, pending.framework) + if (pending) void doSave(pending.openCasePackage, pending.framework, pending.plan) }} /> diff --git a/apps/editor/src/ui/editor/components/NodePropertiesPanel.tsx b/apps/editor/src/ui/editor/components/NodePropertiesPanel.tsx index 07a5081..0651621 100644 --- a/apps/editor/src/ui/editor/components/NodePropertiesPanel.tsx +++ b/apps/editor/src/ui/editor/components/NodePropertiesPanel.tsx @@ -16,6 +16,7 @@ import type { ExternalFrameworkNodeData, } from '../reactflow/types' import type { CFConcept, CFDocument, CFItem, CFItemType, CFLicense, CFSubject } from '@/domain/case/types' +import type { AlignmentStorage } from '@/domain/framework/model/types' import type { ComboboxOption } from '@/ui/shared/components/ui/combobox-input' import { EDUCATION_LEVEL_OPTIONS } from '@/ui/editor/terminology/educationLevels' import type { EducationLevelOption } from '@/ui/editor/terminology/educationLevels' @@ -653,6 +654,26 @@ export default memo(function NodePropertiesPanel({ ) : null} + {/* ── Alignments (framework only) ── */} + {isFramework ? ( + +

+ +
+ Existing associations move when you save. Associations to frameworks not on this server are always stored in this framework. +
+
+ + ) : null} + {/* ── License (framework only) ── */} {isFramework && availableLicenses && availableLicenses.length > 0 ? ( diff --git a/apps/editor/src/ui/editor/reactflow/mapping/fromEditorGraph.ts b/apps/editor/src/ui/editor/reactflow/mapping/fromEditorGraph.ts index ac87c4f..11eec30 100644 --- a/apps/editor/src/ui/editor/reactflow/mapping/fromEditorGraph.ts +++ b/apps/editor/src/ui/editor/reactflow/mapping/fromEditorGraph.ts @@ -73,6 +73,7 @@ export function fromEditorGraph(params: { graph: EditorGraph }): { framework: Fr lastChangeDateTime: doc?.lastChangeDateTime, licenseURI: doc?.licenseURI, publicAccess: doc?.publicAccess === true ? true : undefined, + alignmentStorage: doc?.alignmentStorage, extensions: doc?.extensions, } @@ -124,8 +125,8 @@ export function fromEditorGraph(params: { graph: EditorGraph }): { framework: Fr uri?: string sequenceNumber?: number associationType?: string - originNodeURI?: { uri?: string } - destinationNodeURI?: { uri?: string } + originNodeURI?: { uri?: string; title?: string } + destinationNodeURI?: { uri?: string; title?: string } CFAssociationGroupingURI?: { identifier?: string; title?: string; uri?: string } notes?: string lastChangeDateTime?: string @@ -155,6 +156,8 @@ export function fromEditorGraph(params: { graph: EditorGraph }): { framework: Fr caseUri: edgeData?.cfAssociation?.uri, originUri: edgeData?.cfAssociation?.originNodeURI?.uri, destinationUri: edgeData?.cfAssociation?.destinationNodeURI?.uri, + originTitle: edgeData?.cfAssociation?.originNodeURI?.title, + destinationTitle: edgeData?.cfAssociation?.destinationNodeURI?.title, sequenceNumber: edgeData?.sequenceNumber ?? edgeData?.cfAssociation?.sequenceNumber, originHandle, destinationHandle, @@ -197,6 +200,8 @@ export function fromEditorGraph(params: { graph: EditorGraph }): { framework: Fr caseUri: edgeData?.cfAssociation?.uri, originUri: edgeData?.cfAssociation?.originNodeURI?.uri, destinationUri: edgeData?.cfAssociation?.destinationNodeURI?.uri, + originTitle: edgeData?.cfAssociation?.originNodeURI?.title, + destinationTitle: edgeData?.cfAssociation?.destinationNodeURI?.title, sequenceNumber: edgeData?.cfAssociation?.sequenceNumber, originHandle, destinationHandle, diff --git a/apps/editor/src/ui/editor/reactflow/mapping/toReactFlow.ts b/apps/editor/src/ui/editor/reactflow/mapping/toReactFlow.ts index cc497bd..62b25b1 100644 --- a/apps/editor/src/ui/editor/reactflow/mapping/toReactFlow.ts +++ b/apps/editor/src/ui/editor/reactflow/mapping/toReactFlow.ts @@ -108,6 +108,7 @@ function mapDomainFrameworkToCfDocument(framework: Framework): CFDocument { CFPackageURI: { uri: `urn:case:package:${id}` }, licenseURI: meta.licenseURI, publicAccess: meta.publicAccess === true ? true : undefined, + alignmentStorage: meta.alignmentStorage, extensions: meta.extensions, } } @@ -183,10 +184,12 @@ function mapDomainAssociationToCfAssociation(framework: Framework, association: originNodeURI: { identifier: fromId, uri: s('originUri') ?? `urn:case:item:${fromId}`, + title: s('originTitle'), }, destinationNodeURI: { identifier: toId, uri: s('destinationUri') ?? `urn:case:item:${toId}`, + title: s('destinationTitle'), }, sequenceNumber: n('sequenceNumber'), CFAssociationGroupingURI, diff --git a/apps/editor/src/ui/editor/state/EditorContext.tsx b/apps/editor/src/ui/editor/state/EditorContext.tsx index ccddabe..a569e56 100644 --- a/apps/editor/src/ui/editor/state/EditorContext.tsx +++ b/apps/editor/src/ui/editor/state/EditorContext.tsx @@ -20,6 +20,8 @@ import type { SidePanelMode, RemoteItemLink } from '@/ui/editor/remoteFramework/ import { nextRemoteFrameworkColor } from '@/ui/editor/remoteFramework/remoteFrameworkTypes' import type { CFAssociationGrouping, CFDocument, CFItem, CFItemType, CFLicense, CFSubject, CFConcept } from '@/domain/case/types' import type { FrameworkEdgeRecord } from '@/domain/framework/treeDerivation' +import type { Association } from '@/domain/framework/model/types' +import { NO_INLINE_ALIGNMENT_EDITS, type InlineAlignmentEdits } from '@/domain/framework/inlineAlignmentEdits' import type { AddItemDraft } from '@/ui/editor/components/AddItemDialog' import type { EditorSettings } from '@/ui/editor/components/SettingsModal' import type { EditorGraph } from '@/ui/editor/state/editorFactories' @@ -89,6 +91,12 @@ type EditorContextValue = { addRemoteLink: (_link: RemoteItemLink) => void removeRemoteLink: (_linkId: string) => void updateRemoteLinkType: (_linkId: string, _associationType: string) => void + inlineAlignmentEdits: InlineAlignmentEdits + addInlineAlignment: (_association: Association) => void + removeInlineAlignment: (_associationId: string) => void + updateInlineAlignmentType: (_associationId: string, _associationType: string) => void + /** Call once the edits have been saved and the loaded framework includes them */ + clearInlineAlignmentEdits: () => void removeRemoteFramework: (_nodeId: string) => void sidePanelMode: SidePanelMode setSidePanelMode: (_mode: SidePanelMode) => void @@ -339,7 +347,7 @@ export function EditorProvider({ // ── Reducer state ──────────────────────────────────────────────────── const seed = useMemo(() => initialGraph ?? DEFAULT_GRAPH, [initialGraph]) const initialState: EditorState = useMemo( - () => ({ nodes: seed.nodes, edges: seed.edges, remoteLinks: seed.remoteLinks ?? [], selectedNodeId: null, selectedEdgeId: null, selectedNodeIds: [], selectedEdgeIds: [], layoutVersion: 0, dirty: false }), + () => ({ nodes: seed.nodes, edges: seed.edges, remoteLinks: seed.remoteLinks ?? [], inlineAlignmentEdits: NO_INLINE_ALIGNMENT_EDITS, selectedNodeId: null, selectedEdgeId: null, selectedNodeIds: [], selectedEdgeIds: [], layoutVersion: 0, dirty: false }), [seed], ) const [state, dispatch] = useReducer(editorReducer, initialState) @@ -516,6 +524,22 @@ export function EditorProvider({ dispatch({ type: 'remoteLink/updateType', linkId, associationType }) }, []) + const addInlineAlignment = useCallback((association: Association) => { + dispatch({ type: 'inlineAlignment/add', association }) + }, []) + + const removeInlineAlignment = useCallback((associationId: string) => { + dispatch({ type: 'inlineAlignment/remove', associationId }) + }, []) + + const updateInlineAlignmentType = useCallback((associationId: string, associationType: string) => { + dispatch({ type: 'inlineAlignment/updateType', associationId, associationType }) + }, []) + + const clearInlineAlignmentEdits = useCallback(() => { + dispatch({ type: 'inlineAlignment/clearEdits' }) + }, []) + const removeRemoteFramework = useCallback((nodeId: string) => { dispatch({ type: 'node/removeRemoteFramework', nodeId }) }, []) @@ -860,6 +884,11 @@ export function EditorProvider({ addRemoteLink, removeRemoteLink, updateRemoteLinkType, + inlineAlignmentEdits: state.inlineAlignmentEdits, + addInlineAlignment, + removeInlineAlignment, + updateInlineAlignmentType, + clearInlineAlignmentEdits, removeRemoteFramework, sidePanelMode, setSidePanelMode, @@ -897,6 +926,7 @@ export function EditorProvider({ clearSelection, updateNodeData, updateEdgeData, flipEdge, reconnectEdge, addChild, addDetachedItem, addExternalFramework, addRemoteLink, removeRemoteLink, updateRemoteLinkType, removeRemoteFramework, + state.inlineAlignmentEdits, addInlineAlignment, removeInlineAlignment, updateInlineAlignmentType, clearInlineAlignmentEdits, sidePanelMode, setSidePanelMode, previousSidePanelMode, activeRemoteFrameworkNodeId, setActiveRemoteFrameworkNodeId, openCgeSearchPanel, openRemoteItemsPanel, closeSidePanel, openExternalFrameworkSettings, diff --git a/apps/editor/src/ui/editor/state/editorReducer.test.ts b/apps/editor/src/ui/editor/state/editorReducer.test.ts index fded6b0..389275e 100644 --- a/apps/editor/src/ui/editor/state/editorReducer.test.ts +++ b/apps/editor/src/ui/editor/state/editorReducer.test.ts @@ -1,6 +1,8 @@ import { describe, it, expect } from 'vitest' import { editorReducer, type EditorState } from './editorReducer' import { makeFrameworkNode, makeItemNode, makeEdge } from '@/__tests__/fixtures' +import { NO_INLINE_ALIGNMENT_EDITS } from '@/domain/framework/inlineAlignmentEdits' +import type { Association } from '@/domain/framework/model/types' // ── Helpers ──────────────────────────────────────────────────────────── @@ -9,6 +11,7 @@ function makeState(overrides: Partial = {}): EditorState { nodes: [makeFrameworkNode(), makeItemNode('item-1'), makeItemNode('item-2')], edges: [makeEdge('fw-1', 'item-1'), makeEdge('fw-1', 'item-2')], remoteLinks: [], + inlineAlignmentEdits: NO_INLINE_ALIGNMENT_EDITS, selectedNodeId: null, selectedEdgeId: null, selectedNodeIds: [], @@ -475,3 +478,38 @@ describe('unknown action', () => { expect(next).toBe(state) }) }) + +describe('inline alignment edits', () => { + const association = { + id: 'assoc-new' as Association['id'], + fromItemId: 'item-1' as Association['fromItemId'], + toItemId: 'other-item' as Association['toItemId'], + associationType: 'isRelatedTo' as Association['associationType'], + } + + it('records an added inline alignment and marks the framework dirty', () => { + const next = editorReducer(makeState(), { type: 'inlineAlignment/add', association }) + expect(next.inlineAlignmentEdits.added).toEqual([association]) + expect(next.dirty).toBe(true) + }) + + it('records removals and type changes as dirty edits', () => { + let next = editorReducer(makeState(), { type: 'inlineAlignment/updateType', associationId: 'a-1', associationType: 'exactMatchOf' }) + next = editorReducer(next, { type: 'inlineAlignment/remove', associationId: 'a-2' }) + expect(next.inlineAlignmentEdits).toEqual({ added: [], removedIds: ['a-2'], typeChanges: { 'a-1': 'exactMatchOf' } }) + expect(next.dirty).toBe(true) + }) + + it('clears the edits once they are saved, without touching the dirty flag', () => { + const edited = editorReducer(makeState(), { type: 'inlineAlignment/add', association }) + const cleared = editorReducer({ ...edited, dirty: false }, { type: 'inlineAlignment/clearEdits' }) + expect(cleared.inlineAlignmentEdits).toEqual(NO_INLINE_ALIGNMENT_EDITS) + expect(cleared.dirty).toBe(false) + }) + + it('drops pending edits when another graph is loaded', () => { + const edited = editorReducer(makeState(), { type: 'inlineAlignment/add', association }) + const loaded = editorReducer(edited, { type: 'graph/load', graph: { nodes: [], edges: [] } }) + expect(loaded.inlineAlignmentEdits).toEqual(NO_INLINE_ALIGNMENT_EDITS) + }) +}) diff --git a/apps/editor/src/ui/editor/state/editorReducer.ts b/apps/editor/src/ui/editor/state/editorReducer.ts index c0a0d32..e57c10c 100644 --- a/apps/editor/src/ui/editor/state/editorReducer.ts +++ b/apps/editor/src/ui/editor/state/editorReducer.ts @@ -20,6 +20,14 @@ import type { CFItem } from '@/domain/case/types' import type { EditorGraph } from '@/ui/editor/state/editorFactories' import { DEFAULT_EDGE_MARKER, getEdgeMarkers, getEdgeStyle, makeEdgeLabel } from '@/ui/editor/state/editorFactories' import type { RemoteItemLink } from '@/ui/editor/remoteFramework/remoteFrameworkTypes' +import type { Association } from '@/domain/framework/model/types' +import { + NO_INLINE_ALIGNMENT_EDITS, + addInlineAlignment, + changeInlineAlignmentType, + removeInlineAlignment, + type InlineAlignmentEdits, +} from '@/domain/framework/inlineAlignmentEdits' import { DEFAULT_NODE_HEIGHT, DEFAULT_NODE_WIDTH, HEADER_SAFE_Y, WRAPPER_NODE_CLASS, findNonOverlappingPosition, getClosestHandles, getNodeSize, isFrameworkNode, isItemNode } from '@/ui/editor/state/helpers/nodeGeometry' // ── State ────────────────────────────────────────────────────────────── @@ -28,6 +36,8 @@ export type EditorState = { nodes: CaseEditorNodeType[] edges: CaseEditorEdge[] remoteLinks: RemoteItemLink[] + /** Tree View edits to the framework's inline associations to other frameworks (not canvas edges) */ + inlineAlignmentEdits: InlineAlignmentEdits selectedNodeId: string | null selectedEdgeId: string | null /** IDs of all selected nodes (for multi-selection) */ @@ -59,6 +69,10 @@ export type Action = | { type: 'remoteLink/add'; link: RemoteItemLink } | { type: 'remoteLink/remove'; linkId: string } | { type: 'remoteLink/updateType'; linkId: string; associationType: string } + | { type: 'inlineAlignment/add'; association: Association } + | { type: 'inlineAlignment/remove'; associationId: string } + | { type: 'inlineAlignment/updateType'; associationId: string; associationType: string } + | { type: 'inlineAlignment/clearEdits' } | { type: 'graph/delete'; nodeIds: string[]; edgeIds: string[]; reattachChildren: boolean } | { type: 'layout/apply'; positions: Record } | { type: 'layout/applyHierarchy'; positions: Record; edgeHandles: Record } @@ -550,6 +564,23 @@ export function editorReducer(state: EditorState, action: Action): EditorState { dirty: true, } } + case 'inlineAlignment/add': { + return { ...state, inlineAlignmentEdits: addInlineAlignment(state.inlineAlignmentEdits, action.association), dirty: true } + } + case 'inlineAlignment/remove': { + return { ...state, inlineAlignmentEdits: removeInlineAlignment(state.inlineAlignmentEdits, action.associationId), dirty: true } + } + case 'inlineAlignment/updateType': { + return { + ...state, + inlineAlignmentEdits: changeInlineAlignmentType(state.inlineAlignmentEdits, action.associationId, action.associationType), + dirty: true, + } + } + case 'inlineAlignment/clearEdits': { + // Once saved, the edits are part of the loaded framework. + return { ...state, inlineAlignmentEdits: NO_INLINE_ALIGNMENT_EDITS } + } case 'node/addExternalFramework': { const existingNodes = state.nodes const nodeSize = { w: 280, h: 120 } @@ -704,6 +735,7 @@ export function editorReducer(state: EditorState, action: Action): EditorState { nodes: action.graph.nodes, edges: action.graph.edges, remoteLinks: action.graph.remoteLinks ?? [], + inlineAlignmentEdits: NO_INLINE_ALIGNMENT_EDITS, selectedNodeId: null, selectedEdgeId: null, selectedNodeIds: [], diff --git a/apps/editor/src/ui/editor/treePanel/FrameworkTreeItem.tsx b/apps/editor/src/ui/editor/treePanel/FrameworkTreeItem.tsx index 4fb3cfb..d6f53d8 100644 --- a/apps/editor/src/ui/editor/treePanel/FrameworkTreeItem.tsx +++ b/apps/editor/src/ui/editor/treePanel/FrameworkTreeItem.tsx @@ -1,5 +1,5 @@ import { memo, useState } from 'react' -import { ChevronDown, ChevronRight, Link, Plus } from 'lucide-react' +import { ChevronDown, ChevronRight, ExternalLink, Link, Plus } from 'lucide-react' import type { FrameworkTreeNode } from '@/domain/framework/treeDerivation' import type { CFItem } from '@/domain/case/types' @@ -23,6 +23,8 @@ type Props = { associationCounts?: Map /** Called when the association-count badge is clicked */ onBadgeClick?: (_id: string, _e: React.MouseEvent) => void + /** Show a link out to the item's URI (for rows that aren't on this server) */ + showUriLinks?: boolean } function FrameworkTreeItem({ @@ -42,6 +44,7 @@ function FrameworkTreeItem({ dragOverItemId, associationCounts, onBadgeClick, + showUriLinks, }: Readonly) { const [hovered, setHovered] = useState(false) const cfItem = cfItemsById.get(node.id) @@ -50,6 +53,7 @@ function FrameworkTreeItem({ const hasChildren = node.children.length > 0 const isDraggedOver = dragOverItemId === node.id const associationCount = associationCounts?.get(node.id) ?? 0 + const linkUri = showUriLinks && cfItem?.uri && /^https?:\/\//.test(cfItem.uri) ? cfItem.uri : undefined const rowClass = [ 'flex w-full items-start gap-2 rounded-lg border px-3 py-2 transition-colors', @@ -137,6 +141,19 @@ function FrameworkTreeItem({ {associationCount} )} + {linkUri && ( + e.stopPropagation()} + tabIndex={-1} + > + + + )} {cfItem?.abbreviatedStatement?.trim() ? (

@@ -190,6 +207,7 @@ function FrameworkTreeItem({ dragOverItemId={dragOverItemId} associationCounts={associationCounts} onBadgeClick={onBadgeClick} + showUriLinks={showUriLinks} /> ))} @@ -220,6 +238,7 @@ function areEqual(prev: Readonly, next: Readonly): boolean { prev.dragOverItemId === next.dragOverItemId && prev.associationCounts === next.associationCounts && prev.onBadgeClick === next.onBadgeClick && + prev.showUriLinks === next.showUriLinks && prev.cfItemsById.get(next.node.id) === next.cfItemsById.get(next.node.id) ) } diff --git a/apps/editor/src/ui/editor/treePanel/FrameworkTreeList.tsx b/apps/editor/src/ui/editor/treePanel/FrameworkTreeList.tsx index 158e385..2481532 100644 --- a/apps/editor/src/ui/editor/treePanel/FrameworkTreeList.tsx +++ b/apps/editor/src/ui/editor/treePanel/FrameworkTreeList.tsx @@ -38,6 +38,8 @@ type Props = { associationCounts?: Map /** Called when an association-count badge is clicked */ onBadgeClick?: (_id: string, _e: React.MouseEvent) => void + /** Show a link out to each item's URI (for rows that aren't on this server) */ + showUriLinks?: boolean } export default function FrameworkTreeList({ @@ -65,6 +67,7 @@ export default function FrameworkTreeList({ dragOverItemId, associationCounts, onBadgeClick, + showUriLinks, }: Readonly) { const frameworkSelected = frameworkNodeId !== null && selectedId === frameworkNodeId @@ -142,6 +145,7 @@ export default function FrameworkTreeList({ dragOverItemId={dragOverItemId} associationCounts={associationCounts} onBadgeClick={onBadgeClick} + showUriLinks={showUriLinks} /> ))} diff --git a/apps/editor/src/ui/editor/treePanel/TreePanelView.tsx b/apps/editor/src/ui/editor/treePanel/TreePanelView.tsx index 645df67..b5b43c2 100644 --- a/apps/editor/src/ui/editor/treePanel/TreePanelView.tsx +++ b/apps/editor/src/ui/editor/treePanel/TreePanelView.tsx @@ -4,7 +4,18 @@ import { useEditor } from '@/ui/editor/state/EditorContext' import { buildFrameworkTree, type FrameworkEdgeRecord, type FrameworkTreeNode } from '@/domain/framework/treeDerivation' import type { CFItem } from '@/domain/case/types' import type { HomeFramework } from '@/ui/home/frameworkStore' -import type { Framework } from '@/domain/framework/model/types' +import type { Association, AssociationType, Framework } from '@/domain/framework/model/types' +import type { AssociationId, ItemId } from '@/domain/shared/types' +import { applyInlineAlignmentEdits } from '@/domain/framework/inlineAlignmentEdits' +import { buildAlignmentFrameworkPackage } from '@/domain/framework/alignmentFrameworks' +import { + externalAlignmentsOf, + groupExternalAlignments, + hostedLookupCandidates, + isRemoteTargetId, + type ExternalAlignmentTarget, + type HostedDocumentRef, +} from '@/domain/framework/externalAlignments' import FrameworkTreeList from './FrameworkTreeList' // ── Types ───────────────────────────────────────────────────────────────── @@ -19,6 +30,12 @@ type PendingAssociation = { originUri: string /** Canonical URI of the destination item — preserved for correct serialization when external frameworks are involved */ destinationUri: string + /** + * True for an inline external alignment — stored in this framework's own CFPackage, not in an + * alignment document. Editable only when the framework stores its alignments inline; never + * written to an alignment document. + */ + inline?: boolean } // Module-scope stable reference (never recreated) — passed as a no-op @@ -80,6 +97,12 @@ type Props = { onLoadAlignmentsForTarget?: (targetId: string) => Promise /** Called once on mount to discover all frameworks that already have saved alignments with the source. Used to pre-populate the target list. */ onDiscoverAlignedTargets?: (sourceId: string) => Promise> + /** The framework as loaded — its inline external alignments are shown read-only. */ + loadedFramework?: Framework + /** Changes whenever a save moved associations between the framework and its alignment frameworks — they're rediscovered. */ + alignmentsRevision?: number + /** Resolves item identifiers to the hosted frameworks that hold them; unresolved destinations are shown as remote. */ + onLookupItemDocuments?: (identifiers: string[]) => Promise> } // ── Helpers: build tree data from a domain Framework ────────────────────── @@ -131,6 +154,21 @@ function domainFrameworkToRootIds(framework: Framework): string[] { .map((a) => String(a.fromItemId)) } +/** A remote target's destinations as flat tree rows, labelled with the title stored on each association. */ +function remoteTargetToCfItems(target: ExternalAlignmentTarget): CFItem[] { + const byId = new Map() + for (const a of target.alignments) { + if (byId.has(a.toItemId)) continue + byId.set(a.toItemId, { + identifier: a.toItemId, + uri: a.destinationUri, + fullStatement: a.destinationTitle ?? a.toItemId, + lastChangeDateTime: '', + }) + } + return [...byId.values()].sort((x, y) => x.fullStatement.localeCompare(y.fullStatement, undefined, { numeric: true })) +} + /** Returns all ancestor IDs of itemId, from immediate parent up to the root, in that order. */ function ancestorsFromParentMap(itemId: string, parentMap: Map): string[] { const ancestors: string[] = [] @@ -172,7 +210,7 @@ function findNearestVisible( // ── Component ───────────────────────────────────────────────────────────── -export default function TreePanelView({ availableFrameworks = [], serverFrameworks = [], onLoadTargetFramework, onSaveAlignments, onLoadAlignmentsForTarget, onDiscoverAlignedTargets }: Props) { +export default function TreePanelView({ availableFrameworks = [], serverFrameworks = [], onLoadTargetFramework, onSaveAlignments, onLoadAlignmentsForTarget, onDiscoverAlignedTargets, loadedFramework, onLookupItemDocuments, alignmentsRevision }: Props) { const { nodes, cfItems, @@ -184,14 +222,42 @@ export default function TreePanelView({ availableFrameworks = [], serverFramewor selectedNodeId, onNodesChange, addChild, + inlineAlignmentEdits, + addInlineAlignment, + removeInlineAlignment, + updateInlineAlignmentType, } = useEditor() + // Where this framework stores its associations to hosted frameworks — the author's choice. + // Associations to remote frameworks are always stored in it: there's no alignment framework to put them in. + const storesInline = (cfDocument?.alignmentStorage ?? 'inline') === 'inline' + // The framework's associations as they'll be saved: as loaded, plus this session's Tree View edits. + const editedFramework = useMemo( + () => (loadedFramework ? applyInlineAlignmentEdits(loadedFramework, inlineAlignmentEdits) : undefined), + [loadedFramework, inlineAlignmentEdits], + ) + const activeFrameworkId = frameworkNodeId // ── Core state ── const [targets, setTargets] = useState([]) const [expandedTargetId, setExpandedTargetId] = useState(null) const [pendingAssociations, setPendingAssociations] = useState([]) + // Inline external alignments, grouped by the hosted framework or remote source they point into. + const [externalTargets, setExternalTargets] = useState([]) + // Which hosted framework holds each destination looked up so far (misses are remembered too), + // so edits only send new destinations to the server. + const hostedDocumentsRef = useRef>({}) + const lookedUpIdsRef = useRef>(new Set()) + const inlineAssociations = useMemo( + () => externalTargets.flatMap((t) => t.alignments.map((a) => ({ ...a, toFrameworkId: t.id, inline: true }))), + [externalTargets], + ) + // Everything drawn in the panel: alignment-document associations and inline ones. + const shownAssociations = useMemo( + () => (inlineAssociations.length > 0 ? [...pendingAssociations, ...inlineAssociations] : pendingAssociations), + [pendingAssociations, inlineAssociations], + ) const [dragOverItemId, setDragOverItemId] = useState(null) const [loadingTargetIds, setLoadingTargetIds] = useState>(() => new Set()) const [loadedTargetIds, setLoadedTargetIds] = useState>(() => new Set()) @@ -241,8 +307,8 @@ export default function TreePanelView({ availableFrameworks = [], serverFramewor const [leftHeaderHeight, setLeftHeaderHeight] = useState(undefined) // Stable refs for values used inside recalculateLines (avoids stale closures) - const pendingRef = useRef(pendingAssociations) - pendingRef.current = pendingAssociations + const shownRef = useRef(shownAssociations) + shownRef.current = shownAssociations const expandedTargetIdRef = useRef(expandedTargetId) expandedTargetIdRef.current = expandedTargetId @@ -274,18 +340,29 @@ export default function TreePanelView({ availableFrameworks = [], serverFramewor [expandedTargetId, availableFrameworks], ) + // Remote targets aren't frameworks on this server — their rows are the destinations themselves. + const expandedRemoteTarget = useMemo( + () => (expandedTargetId && isRemoteTargetId(expandedTargetId) ? externalTargets.find((t) => t.id === expandedTargetId) ?? null : null), + [expandedTargetId, externalTargets], + ) + const targetCfItems = useMemo( - () => (isSelfTarget ? cfItems : expandedTargetFramework ? domainFrameworkToCfItems(expandedTargetFramework.framework) : []), - [isSelfTarget, cfItems, expandedTargetFramework], + () => { + if (isSelfTarget) return cfItems + if (expandedRemoteTarget) return remoteTargetToCfItems(expandedRemoteTarget) + return expandedTargetFramework ? domainFrameworkToCfItems(expandedTargetFramework.framework) : [] + }, + [isSelfTarget, cfItems, expandedRemoteTarget, expandedTargetFramework], ) const rightRoots = useMemo(() => { if (isSelfTarget) return leftRoots + if (expandedRemoteTarget) return buildFrameworkTree([], targetCfItems.map((i) => i.identifier)) if (!expandedTargetFramework) return [] const edges = domainFrameworkToEdges(expandedTargetFramework.framework) const roots = domainFrameworkToRootIds(expandedTargetFramework.framework) return buildFrameworkTree(edges, roots) - }, [isSelfTarget, leftRoots, expandedTargetFramework]) + }, [isSelfTarget, leftRoots, expandedRemoteTarget, targetCfItems, expandedTargetFramework]) const targetCfItemsById = useMemo( () => (isSelfTarget ? cfItemsById : new Map(targetCfItems.map((item) => [item.identifier, item]))), @@ -310,15 +387,27 @@ export default function TreePanelView({ availableFrameworks = [], serverFramewor return serverFrameworks.filter((f) => !loadedIds.has(f.id)) }, [serverFrameworks, availableFrameworks]) - // Associated Frameworks list — the active framework always pinned first, then alphabetical by title. + // A target's title: from the loaded framework, else from the inline-alignment lookup or the server list. + const targetTitle = useCallback( + (id: string) => + availableFrameworks.find((f) => f.id === id)?.cfDocument.title ?? + externalTargets.find((t) => t.id === id)?.title ?? + serverFrameworks.find((f) => f.id === id)?.title ?? + id, + [availableFrameworks, externalTargets, serverFrameworks], + ) + + // Associated Frameworks list — the active framework always pinned first, remote sources last, + // then alphabetical by title. const sortedTargets = useMemo(() => { - const titleFor = (id: string) => availableFrameworks.find((f) => f.id === id)?.cfDocument.title ?? id return targets.slice().sort((a, b) => { if (a.id === activeFrameworkId) return -1 if (b.id === activeFrameworkId) return 1 - return titleFor(a.id).localeCompare(titleFor(b.id)) + const remoteOrder = Number(isRemoteTargetId(a.id)) - Number(isRemoteTargetId(b.id)) + if (remoteOrder !== 0) return remoteOrder + return targetTitle(a.id).localeCompare(targetTitle(b.id)) }) - }, [targets, availableFrameworks, activeFrameworkId]) + }, [targets, activeFrameworkId, targetTitle]) // ── Modal: which frameworks can still be added ── @@ -342,23 +431,23 @@ export default function TreePanelView({ availableFrameworks = [], serverFramewor const leftAssociationCounts = useMemo(() => { const counts = new Map() - for (const a of pendingAssociations) { + for (const a of shownAssociations) { if (a.toFrameworkId === expandedTargetId) { counts.set(a.fromItemId, (counts.get(a.fromItemId) ?? 0) + 1) } } return counts - }, [pendingAssociations, expandedTargetId]) + }, [shownAssociations, expandedTargetId]) const rightAssociationCounts = useMemo(() => { const counts = new Map() - for (const a of pendingAssociations) { + for (const a of shownAssociations) { if (a.toFrameworkId === expandedTargetId) { counts.set(a.toItemId, (counts.get(a.toItemId) ?? 0) + 1) } } return counts - }, [pendingAssociations, expandedTargetId]) + }, [shownAssociations, expandedTargetId]) // ── SVG line calculation ── @@ -375,7 +464,7 @@ export default function TreePanelView({ availableFrameworks = [], serverFramewor const leftScrollEl = leftPanel.querySelector('[data-scroll-container]') const rightScrollEl = rightPanel.querySelector('[data-scroll-container]') - const computed = pendingRef.current + const computed = shownRef.current .filter((assoc) => assoc.toFrameworkId === expandedTargetIdRef.current) .flatMap((assoc) => { // Use nearest visible ancestor if the exact item is collapsed inside a parent @@ -422,7 +511,7 @@ export default function TreePanelView({ availableFrameworks = [], serverFramewor const id = requestAnimationFrame(recalculateLines) return () => cancelAnimationFrame(id) // eslint-disable-next-line react-hooks/exhaustive-deps - }, [pendingAssociations, expandedTargetId, expandedTargetFramework?.id ?? null, leftExpandedIds, rightExpandedIds, recalculateLines]) + }, [shownAssociations, expandedTargetId, expandedTargetFramework?.id ?? null, expandedRemoteTarget, leftExpandedIds, rightExpandedIds, recalculateLines]) // Recalculate on scroll and resize useEffect(() => { @@ -472,6 +561,9 @@ export default function TreePanelView({ availableFrameworks = [], serverFramewor // then eagerly load their associations so counts are visible without expanding. useEffect(() => { if (!activeFrameworkId || !onDiscoverAlignedTargets) return + // Anything loaded from alignment frameworks before a save moved associations may be gone. + setPendingAssociations([]) + setLoadedTargetIds(new Set()) void (async () => { const discovered = await onDiscoverAlignedTargets(activeFrameworkId) if (!discovered.length) return @@ -514,11 +606,53 @@ export default function TreePanelView({ availableFrameworks = [], serverFramewor }), ) })() - }, [activeFrameworkId, onDiscoverAlignedTargets, onLoadAlignmentsForTarget]) + }, [activeFrameworkId, onDiscoverAlignedTargets, onLoadAlignmentsForTarget, alignmentsRevision]) + + // Inline external alignments: the framework's own associations to items outside it. The server + // says which hosted framework holds each destination; everything else is remote. Each hosted + // framework and remote source joins the target list. + useEffect(() => { + const alignments = editedFramework ? externalAlignmentsOf(editedFramework) : [] + if (alignments.length === 0) { + setExternalTargets([]) + return + } + + let cancelled = false + const show = (hostedDocuments: Record) => { + if (cancelled) return + const grouped = groupExternalAlignments(alignments, hostedDocuments) + setExternalTargets(grouped) + setTargets((prev) => { + const existingIds = new Set(prev.map((t) => t.id)) + const toAdd = grouped + .filter((t) => !existingIds.has(t.id)) + .map((t) => ({ id: t.id, alignmentDocId: globalThis.crypto?.randomUUID?.() ?? `align-${Date.now()}-${t.id}`, hasUnsavedChanges: false })) + return toAdd.length > 0 ? [...prev, ...toAdd] : prev + }) + } + + const candidates = hostedLookupCandidates(alignments).filter((id) => !lookedUpIdsRef.current.has(id)) + if (!onLookupItemDocuments || candidates.length === 0) { + show(hostedDocumentsRef.current) + } else { + onLookupItemDocuments(candidates) + .then((found) => { + candidates.forEach((id) => lookedUpIdsRef.current.add(id)) + hostedDocumentsRef.current = { ...hostedDocumentsRef.current, ...found } + show(hostedDocumentsRef.current) + }) + .catch((err: unknown) => { + console.warn('[TreePanelView] Failed to resolve hosted frameworks of inline alignments:', err) + show(hostedDocumentsRef.current) + }) + } + return () => { cancelled = true } + }, [editedFramework, onLookupItemDocuments]) // When expanding a pre-populated target whose framework data isn't loaded yet, fetch it useEffect(() => { - if (!expandedTargetId || !onLoadTargetFramework) return + if (!expandedTargetId || !onLoadTargetFramework || isRemoteTargetId(expandedTargetId)) return const isLoaded = availableFrameworks.some((f) => f.id === expandedTargetId) if (isLoaded) return const id = expandedTargetId @@ -536,7 +670,7 @@ export default function TreePanelView({ availableFrameworks = [], serverFramewor // Load associations when a target is expanded for the first time useEffect(() => { - if (!expandedTargetId || loadedTargetIds.has(expandedTargetId) || !onLoadAlignmentsForTarget) return + if (!expandedTargetId || loadedTargetIds.has(expandedTargetId) || !onLoadAlignmentsForTarget || isRemoteTargetId(expandedTargetId)) return const id = expandedTargetId setLoadingTargetIds((prev) => new Set([...prev, id])) void onLoadAlignmentsForTarget(id) @@ -601,6 +735,39 @@ export default function TreePanelView({ availableFrameworks = [], serverFramewor const originUri = fromCfItem?.uri ?? `urn:case:item:${fromItemId}` const destinationUri = toCfItem?.uri ?? `urn:case:item:${toItemId}` + // Inline: the association is stored in this framework and saved with it. Self alignments + // aren't associations to another framework and keep going to an alignment document. + if (expandedTargetId !== activeFrameworkId && (storesInline || isRemoteTargetId(expandedTargetId))) { + // A remote destination keeps what's known about it from the associations already pointing there. + const known = inlineAssociations.find((a) => a.toItemId === toItemId) + const knownMetadata = known + ? editedFramework?.associations.get(known.id as AssociationId)?.metadata + : undefined + if (!isRemoteTargetId(expandedTargetId)) { + hostedDocumentsRef.current = { + ...hostedDocumentsRef.current, + [toItemId]: { identifier: expandedTargetId, title: targetTitle(expandedTargetId) }, + } + lookedUpIdsRef.current.add(toItemId) + } + const association: Association = { + id: (globalThis.crypto?.randomUUID?.() ?? `assoc-${Date.now()}`) as AssociationId, + fromItemId: fromItemId as ItemId, + toItemId: toItemId as ItemId, + associationType: 'isRelatedTo' as AssociationType, + metadata: { + originUri, + destinationUri: knownMetadata?.destinationUri ?? destinationUri, + destinationTitle: knownMetadata?.destinationTitle ?? + (toCfItem?.humanCodingScheme ?? toCfItem?.abbreviatedStatement ?? toCfItem?.fullStatement)?.slice(0, 200), + ...(knownMetadata?.destinationTargetType ? { destinationTargetType: knownMetadata.destinationTargetType } : {}), + }, + } + addInlineAlignment(association) + setDragOverItemId(null) + return + } + setPendingAssociations((prev) => [ ...prev, { @@ -615,7 +782,7 @@ export default function TreePanelView({ availableFrameworks = [], serverFramewor ]) setTargets((prev) => prev.map((t) => (t.id === expandedTargetId ? { ...t, hasUnsavedChanges: true } : t))) setDragOverItemId(null) - }, [expandedTargetId, cfItemsById, targetCfItemsById]) + }, [expandedTargetId, cfItemsById, targetCfItemsById, storesInline, activeFrameworkId, inlineAssociations, editedFramework, targetTitle, addInlineAlignment]) const handleAssociationTypeChange = useCallback((id: string, newType: string) => { setPendingAssociations((prev) => { @@ -629,7 +796,7 @@ export default function TreePanelView({ availableFrameworks = [], serverFramewor // Clicking a solid line collapses both panels by one level (inverse of dashed-line click). const handleSolidLineClick = useCallback((assocId: string) => { - const assoc = pendingRef.current.find((a) => a.id === assocId) + const assoc = shownRef.current.find((a) => a.id === assocId) if (!assoc) return const leftParentId = leftParentMapRef.current.get(assoc.fromItemId) const rightParentId = rightParentMapRef.current.get(assoc.toItemId) @@ -643,7 +810,7 @@ export default function TreePanelView({ availableFrameworks = [], serverFramewor // Clicking a dashed line expands both panels to reveal the actual aligned items. const handleDashedLineClick = useCallback((assocId: string) => { - const assoc = pendingRef.current.find((a) => a.id === assocId) + const assoc = shownRef.current.find((a) => a.id === assocId) if (!assoc) return const leftAncestors = ancestorsFromParentMap(assoc.fromItemId, leftParentMapRef.current) const rightAncestors = ancestorsFromParentMap(assoc.toItemId, rightParentMapRef.current) @@ -678,10 +845,11 @@ export default function TreePanelView({ availableFrameworks = [], serverFramewor // Associations relevant to the open popover const popoverAssociations = useMemo(() => { if (!popover) return [] - return pendingAssociations.filter((a) => - popover.side === 'left' ? a.fromItemId === popover.itemId : a.toItemId === popover.itemId, + return shownAssociations.filter((a) => + a.toFrameworkId === expandedTargetId && + (popover.side === 'left' ? a.fromItemId === popover.itemId : a.toItemId === popover.itemId), ) - }, [popover, pendingAssociations]) + }, [popover, shownAssociations, expandedTargetId]) // ── Save alignments ── @@ -695,36 +863,19 @@ export default function TreePanelView({ availableFrameworks = [], serverFramewor setSaveError(null) try { const now = new Date().toISOString() - const sourceDocId = activeFrameworkId ?? '' - const targetDocId = targetId - // Both frameworks are stored on this server — use their canonical server URIs so - // participants are referenced consistently regardless of import origin. - const sourceDocUri = `/ims/case/v1p1/CFDocuments/${sourceDocId}` - const targetDocUri = `/ims/case/v1p1/CFDocuments/${targetDocId}` const sourceTitle = frameworkInfo.title - const targetTitle = isSelfTarget ? sourceTitle : (expandedTargetFramework.cfDocument.title ?? targetDocId) - const targetAssociations = pendingAssociations.filter((a) => a.toFrameworkId === targetId) - const cfPackage = { - CFDocument: { - identifier: target.alignmentDocId, - uri: `/ims/case/v1p1/CFDocuments/${target.alignmentDocId}`, - title: isSelfTarget ? `Internal Alignment: ${sourceTitle}` : `Alignment: ${sourceTitle} → ${targetTitle}`, - creator: cfDocument?.creator ?? 'OpenCASE', - frameworkType: 'Alignment', - lastChangeDateTime: now, - extensions: { - 'ext:opencase': { - alignmentParticipants: [ - { identifier: sourceDocId, uri: sourceDocUri }, - { identifier: targetDocId, uri: targetDocUri }, - ], - }, - }, + const cfPackage = buildAlignmentFrameworkPackage({ + identifier: target.alignmentDocId, + source: { identifier: activeFrameworkId ?? '', title: sourceTitle }, + target: { + identifier: targetId, + title: isSelfTarget ? sourceTitle : (expandedTargetFramework.cfDocument.title ?? targetId), }, - CFItems: [], - CFAssociations: targetAssociations.map((a) => { + creator: cfDocument?.creator ?? 'OpenCASE', + now, + associations: targetAssociations.map((a) => { const fromItem = cfItems.find((i) => i.identifier === a.fromItemId) const toItem = targetCfItems.find((i) => i.identifier === a.toItemId) const fromTitle = (fromItem?.humanCodingScheme ?? fromItem?.abbreviatedStatement ?? fromItem?.fullStatement ?? a.fromItemId).slice(0, 200) @@ -738,7 +889,7 @@ export default function TreePanelView({ availableFrameworks = [], serverFramewor lastChangeDateTime: now, } }), - } + }) await onSaveAlignments(cfPackage) @@ -888,7 +1039,7 @@ export default function TreePanelView({ availableFrameworks = [], serverFramewor onToggleExpand={handleLeftToggleExpand} onSelect={handleSelect} onAddChild={handleAddChild} - isDraggable={Boolean(expandedTargetFramework)} + isDraggable={Boolean(expandedTargetFramework) || expandedRemoteTarget !== null} onDragStart={NOOP_DRAG_START} associationCounts={leftAssociationCounts} onBadgeClick={handleLeftBadgeClick} @@ -940,8 +1091,10 @@ export default function TreePanelView({ availableFrameworks = [], serverFramewor ) : ( sortedTargets.map((target) => { const fw = availableFrameworks.find((f) => f.id === target.id) + const isRemote = isRemoteTargetId(target.id) const isExpanded = target.id === expandedTargetId - const targetAssocCount = pendingAssociations.filter((a) => a.toFrameworkId === target.id).length + const targetAssocCount = shownAssociations.filter((a) => a.toFrameworkId === target.id).length + const pendingAssocCount = pendingAssociations.filter((a) => a.toFrameworkId === target.id).length const isLoading = loadingTargetIds.has(target.id) const isSelf = target.id === activeFrameworkId @@ -956,8 +1109,9 @@ export default function TreePanelView({ availableFrameworks = [], serverFramewor className={`h-4 w-4 shrink-0 text-slate-400 transition-transform ${isExpanded ? 'rotate-90' : ''}`} /> - {fw?.cfDocument.title ?? target.id} + {targetTitle(target.id)} {isSelf && (this framework)} + {isRemote && (not on this server)} {isLoading && ( Loading… @@ -972,11 +1126,11 @@ export default function TreePanelView({ availableFrameworks = [], serverFramewor )} - {isExpanded && fw && ( + {isExpanded && (fw || isRemote) && (

{onSaveAlignments && target.hasUnsavedChanges && (
- {targetAssocCount > 0 - ? `${targetAssocCount} association${targetAssocCount !== 1 ? 's' : ''} — unsaved` + {pendingAssocCount > 0 + ? `${pendingAssocCount} association${pendingAssocCount !== 1 ? 's' : ''} — unsaved` : 'Unsaved changes'}
@@ -1060,10 +1215,20 @@ export default function TreePanelView({ availableFrameworks = [], serverFramewor key={assoc.id} className="flex items-center gap-2 rounded-lg px-1 py-1.5 hover:bg-slate-50" > + {assoc.inline && !storesInline && !isRemoteTargetId(assoc.toFrameworkId) ? ( + + {ALIGNMENT_ASSOCIATION_TYPES.find((t) => t.value === assoc.associationType)?.label ?? assoc.associationType} + + ) : ( + )} {label} - + {(!assoc.inline || storesInline || isRemoteTargetId(assoc.toFrameworkId)) && ( + + )}
) })} diff --git a/apps/editor/src/ui/home/frameworkStore.ts b/apps/editor/src/ui/home/frameworkStore.ts index fec2441..d952b22 100644 --- a/apps/editor/src/ui/home/frameworkStore.ts +++ b/apps/editor/src/ui/home/frameworkStore.ts @@ -120,6 +120,7 @@ function createCfDocumentFromFramework(framework: Framework): CFDocument { CFPackageURI: { uri: `urn:case:package:${id}` }, licenseURI: meta.licenseURI, publicAccess: meta.publicAccess === true ? true : undefined, + alignmentStorage: meta.alignmentStorage, } } diff --git a/apps/opencase/src/application/case/endpoints/CreateFramework.ts b/apps/opencase/src/application/case/endpoints/CreateFramework.ts index cd30f99..558ebb0 100644 --- a/apps/opencase/src/application/case/endpoints/CreateFramework.ts +++ b/apps/opencase/src/application/case/endpoints/CreateFramework.ts @@ -25,7 +25,15 @@ export interface CreateFrameworkCommand { export type CreateFrameworkResult = | { status: 'created', docId: string } - | { status: 'published', docId: string, forked?: boolean, isModifiedFromSource?: boolean, sourcePackageURI?: string } + | { + status: 'published' + docId: string + forked?: boolean + /** On a fork: each old document and item identifier → its new one. */ + forkedIdentifiers?: Record + isModifiedFromSource?: boolean + sourcePackageURI?: string + } | { status: 'unchanged', docId: string, isModifiedFromSource?: boolean, sourcePackageURI?: string } function stableStringify (value: any): string { @@ -153,6 +161,7 @@ export class CreateFramework { let cfRubricsPayload = payload.CFRubrics ?? [] let preserveUris: { preserveUris: boolean } | undefined let forked = false + let forkedIdentifiers: Record | undefined let resultIsModifiedFromSource: boolean | undefined let resultSourcePackageURI: string | undefined @@ -244,7 +253,7 @@ export class CreateFramework { // items, associations, and rubrics. The document's storage // location doesn't change — it keeps living under its pre-fork // storage key even though its own identifier just changed. - const minted = mintForkedIdentifiers( + const { bundle: minted, forkedIdentifiers: mintedIdentifiers } = mintForkedIdentifiers( { CFDocument: cfDocPayload, CFItems: cfItemsPayload, CFAssociations: cfAssociationsPayload, CFRubrics: cfRubricsPayload }, tenantId, caseVersion @@ -255,6 +264,7 @@ export class CreateFramework { cfRubricsPayload = minted.CFRubrics ?? [] preserveUris = { preserveUris: true } forked = true + forkedIdentifiers = mintedIdentifiers newIsModifiedFromSource = true } @@ -313,6 +323,7 @@ export class CreateFramework { status: 'published', docId, forked: true, + forkedIdentifiers, isModifiedFromSource: resultIsModifiedFromSource, sourcePackageURI: resultSourcePackageURI } diff --git a/apps/opencase/src/application/case/endpoints/ImportFramework.ts b/apps/opencase/src/application/case/endpoints/ImportFramework.ts index 4c3b638..d789139 100644 --- a/apps/opencase/src/application/case/endpoints/ImportFramework.ts +++ b/apps/opencase/src/application/case/endpoints/ImportFramework.ts @@ -55,6 +55,9 @@ function injectSourceProvenance ( ...(endpointUrl ? { sourcePackageURI: endpointUrl } : {}), isModifiedFromSource: false, importedAt: new Date().toISOString(), + // An imported CFPackage carries its associations to other frameworks inline, + // and there is no way to import alignment frameworks. + alignmentStorage: 'inline', } if (documentFlags?.readOnly === true) { diff --git a/apps/opencase/src/application/case/endpoints/LookupItemDocuments.ts b/apps/opencase/src/application/case/endpoints/LookupItemDocuments.ts new file mode 100644 index 0000000..26e272f --- /dev/null +++ b/apps/opencase/src/application/case/endpoints/LookupItemDocuments.ts @@ -0,0 +1,38 @@ +import { type CaseVersion, type TenantId } from '../../../domain/case/value-objects/Identifiers' +import { type FileFrameworkStore, getOpenCaseArchived } from '../../../infrastructure/persistence/file/FileFrameworkStore' + +export interface LookupItemDocumentsQuery { + tenantId: TenantId + caseVersion: CaseVersion + identifiers: string[] +} + +export interface ItemDocumentRef { + /** Public identifier of the CFDocument that holds the item */ + identifier: string + title: string +} + +/** + * Resolves item identifiers to the hosted CFDocument that holds each one. + * + * Answered from the in-memory item and document indexes only — no package is + * loaded — so it stays cheap for the hundreds of identifiers a large framework's + * cross-framework associations can reference. Identifiers that aren't on this + * server, or whose framework is archived, are left out of the result. + */ +export class LookupItemDocuments { + constructor (private readonly store: FileFrameworkStore) {} + + execute (query: LookupItemDocumentsQuery): Record { + const result: Record = {} + for (const identifier of new Set(query.identifiers)) { + const storageKey = this.store.getStorageKeyForItem(query.tenantId, query.caseVersion, identifier) + if (!storageKey) continue + const meta = this.store.getDocumentMetadata(query.tenantId, query.caseVersion, storageKey) + if (!meta || getOpenCaseArchived(meta)) continue + result[identifier] = { identifier: meta.sourcedId, title: meta.title } + } + return result + } +} diff --git a/apps/opencase/src/application/case/endpoints/__tests__/CreateFramework.fork.test.ts b/apps/opencase/src/application/case/endpoints/__tests__/CreateFramework.fork.test.ts index 4092f8c..b5c66a6 100644 --- a/apps/opencase/src/application/case/endpoints/__tests__/CreateFramework.fork.test.ts +++ b/apps/opencase/src/application/case/endpoints/__tests__/CreateFramework.fork.test.ts @@ -234,6 +234,11 @@ describe('CreateFramework — mirror fork behavior', () => { expect(savedPkg.document.sourcedId).not.toBe(docId) expect(savedPkg.document.toJSON().uri).toBe(`/ims/case/v1p1/CFDocuments/${savedPkg.document.sourcedId}`) expect(savedPkg.items[0].sourcedId).not.toBe('item-1') + // The caller learns each new identifier, to update anything outside the package that refers to the old ones. + expect((result as any).forkedIdentifiers).toEqual({ + [docId]: savedPkg.document.sourcedId, + 'item-1': savedPkg.items[0].sourcedId + }) const ext = savedPkg.document.toJSON().extensions as any expect(ext['ext:opencase'].isModifiedFromSource).toBe(true) expect(ext['ext:opencase'].sourcePackageURI).toBe('https://source.example.org/ims/case/v1p1/CFPackages/doc-123') 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 a114071..7af884c 100644 --- a/apps/opencase/src/application/case/endpoints/__tests__/ImportFramework.test.ts +++ b/apps/opencase/src/application/case/endpoints/__tests__/ImportFramework.test.ts @@ -121,6 +121,19 @@ describe('ImportFramework', () => { expect(savedExtensions?.['ext:opencase']?.isModifiedFromSource).toBe(false) }) + it('stores associations to other frameworks inline, whatever the source file says', async () => { + await importFramework.execute({ + tenantId, + caseVersion, + cfPackage: { + CFDocument: { ...cfDocument, extensions: { 'ext:opencase': { alignmentStorage: 'alignmentFramework' } } } + } + }) + + const savedPkg = mockRepository.saveNewVersion.mock.calls[0][2] + expect(savedPkg.document.toJSON().extensions['ext:opencase'].alignmentStorage).toBe('inline') + }) + it('preserves the source uri as-is (does not rewrite it onto this instance) when importing from a URL', async () => { const foreignDocument = { ...cfDocument, diff --git a/apps/opencase/src/application/case/endpoints/__tests__/LookupItemDocuments.test.ts b/apps/opencase/src/application/case/endpoints/__tests__/LookupItemDocuments.test.ts new file mode 100644 index 0000000..1ec238c --- /dev/null +++ b/apps/opencase/src/application/case/endpoints/__tests__/LookupItemDocuments.test.ts @@ -0,0 +1,68 @@ +import { LookupItemDocuments } from '../LookupItemDocuments' +import { FileFrameworkStore, type DocumentMetadata } from '../../../../infrastructure/persistence/file/FileFrameworkStore' + +describe('LookupItemDocuments', () => { + const tenantId = 'test-tenant' + const caseVersion = '1.1' + + let mockStore: jest.Mocked + let lookup: LookupItemDocuments + + // storage key → document metadata; item id → storage key + const docs: Record> = { + 'key-a': { sourcedId: 'doc-a', title: 'Framework A' }, + 'key-b': { sourcedId: 'doc-b', title: 'Framework B' }, + 'key-archived': { sourcedId: 'doc-archived', title: 'Archived', openCaseExtensions: { archived: true } } + } + const items: Record = { + 'item-a1': 'key-a', + 'item-a2': 'key-a', + 'item-b1': 'key-b', + 'item-archived': 'key-archived', + 'item-orphan': 'key-missing' + } + + beforeEach(() => { + mockStore = { + getStorageKeyForItem: jest.fn((_t: string, _v: string, id: string) => items[id] ?? null), + getDocumentMetadata: jest.fn((_t: string, _v: string, key: string) => (docs[key] as DocumentMetadata) ?? null) + } as any + lookup = new LookupItemDocuments(mockStore) + }) + + it('maps each hosted item to the public identifier and title of its document', () => { + const result = lookup.execute({ tenantId, caseVersion, identifiers: ['item-a1', 'item-a2', 'item-b1'] }) + + expect(result).toEqual({ + 'item-a1': { identifier: 'doc-a', title: 'Framework A' }, + 'item-a2': { identifier: 'doc-a', title: 'Framework A' }, + 'item-b1': { identifier: 'doc-b', title: 'Framework B' } + }) + }) + + it('omits identifiers that are not on this server', () => { + const result = lookup.execute({ tenantId, caseVersion, identifiers: ['item-a1', 'goal-4.2.15', 'unknown'] }) + + expect(Object.keys(result)).toEqual(['item-a1']) + }) + + it('omits items whose framework is archived', () => { + const result = lookup.execute({ tenantId, caseVersion, identifiers: ['item-archived'] }) + + expect(result).toEqual({}) + }) + + it('omits items whose indexed document has no metadata', () => { + const result = lookup.execute({ tenantId, caseVersion, identifiers: ['item-orphan'] }) + + expect(result).toEqual({}) + }) + + it('looks each identifier up once, scoped to the tenant and version', () => { + lookup.execute({ tenantId, caseVersion, identifiers: ['item-a1', 'item-a1'] }) + + expect(mockStore.getStorageKeyForItem).toHaveBeenCalledTimes(1) + expect(mockStore.getStorageKeyForItem).toHaveBeenCalledWith(tenantId, caseVersion, 'item-a1') + expect(mockStore.getDocumentMetadata).toHaveBeenCalledWith(tenantId, caseVersion, 'key-a') + }) +}) diff --git a/apps/opencase/src/application/case/services/__tests__/mirrorFork.test.ts b/apps/opencase/src/application/case/services/__tests__/mirrorFork.test.ts index 3dd8492..28b63eb 100644 --- a/apps/opencase/src/application/case/services/__tests__/mirrorFork.test.ts +++ b/apps/opencase/src/application/case/services/__tests__/mirrorFork.test.ts @@ -121,6 +121,28 @@ describe('stripNonDataFields', () => { }) }) +describe('stripNonDataFields — association node titles', () => { + const bundle = (originTitle: string, destinationId: string) => ({ + CFDocument: { identifier: 'doc-1', title: 'Doc' }, + CFItems: [], + CFAssociations: [{ + identifier: 'assoc-1', + associationType: 'isRelatedTo', + originNodeURI: { title: originTitle, identifier: 'item-1', uri: '/ims/case/v1p1/CFItems/item-1' }, + destinationNodeURI: { title: 'Other', identifier: destinationId, uri: `/ims/case/v1p1/CFItems/${destinationId}` } + }], + CFRubrics: [] + }) + + it('ignores the titles the frontend rebuilds from each node', () => { + expect(stripNonDataFields(bundle('MATH.313 Count on.', 'item-2'))).toEqual(stripNonDataFields(bundle('Count on.', 'item-2'))) + }) + + it('still sees an association pointing somewhere else', () => { + expect(stripNonDataFields(bundle('Count on.', 'item-2'))).not.toEqual(stripNonDataFields(bundle('Count on.', 'item-3'))) + }) +}) + describe('mintForkedIdentifiers', () => { const tenantId = 'test-tenant' const caseVersion = '1.1' @@ -151,7 +173,7 @@ describe('mintForkedIdentifiers', () => { const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i it('mints fresh UUID identifiers and local URIs for every entity', () => { - const minted = mintForkedIdentifiers(payload(), tenantId, caseVersion) + const { bundle: minted } = mintForkedIdentifiers(payload(), tenantId, caseVersion) expect(minted.CFDocument.identifier).toMatch(uuidRegex) expect(minted.CFDocument.identifier).not.toBe('source-doc') @@ -173,7 +195,7 @@ describe('mintForkedIdentifiers', () => { }) it('remaps an association origin reference to the new item identifier', () => { - const minted = mintForkedIdentifiers(payload(), tenantId, caseVersion) + const { bundle: minted } = mintForkedIdentifiers(payload(), tenantId, caseVersion) const newItem1Id = minted.CFItems![0].identifier const assoc = minted.CFAssociations![0] @@ -182,18 +204,37 @@ describe('mintForkedIdentifiers', () => { }) it('remaps an association reference to the document-as-node case', () => { - const minted = mintForkedIdentifiers(payload(), tenantId, caseVersion) + const { bundle: minted } = mintForkedIdentifiers(payload(), tenantId, caseVersion) const assoc = minted.CFAssociations![0] expect(assoc.destinationNodeURI.identifier).toBe(minted.CFDocument.identifier) expect(assoc.destinationNodeURI.uri).toBe(minted.CFDocument.uri) }) - it('throws on a dangling association reference', () => { - const p = payload() - p.CFAssociations[0].originNodeURI.identifier = 'does-not-exist' + it('leaves an association reference to a node in another framework unchanged', () => { + const p: any = payload() + const otherFrameworkItem = { title: 'Other', identifier: 'other-framework-item', uri: 'https://other.example.org/ims/case/v1p1/CFItems/other-framework-item' } + p.CFAssociations[0].destinationNodeURI = { ...otherFrameworkItem } + + const { bundle: minted } = mintForkedIdentifiers(p, tenantId, caseVersion) + + const assoc = minted.CFAssociations![0] + expect(assoc.destinationNodeURI).toEqual(otherFrameworkItem) + expect(assoc.originNodeURI.identifier).toBe(minted.CFItems![0].identifier) + }) + + it('returns each old document and item identifier\'s new one', () => { + const { bundle: minted, forkedIdentifiers } = mintForkedIdentifiers(payload(), tenantId, caseVersion) + + expect(forkedIdentifiers['source-doc']).toBe(minted.CFDocument.identifier) + expect(forkedIdentifiers['source-item-1']).toBe(minted.CFItems![0].identifier) + }) + + it('throws on an association reference with no identifier', () => { + const p: any = payload() + delete p.CFAssociations[0].originNodeURI.identifier - expect(() => mintForkedIdentifiers(p, tenantId, caseVersion)).toThrow(/references unknown node/) + expect(() => mintForkedIdentifiers(p, tenantId, caseVersion)).toThrow(/has no identifier/) }) it('keeps a reference URI\'s identifier but rebases it onto this instance, since forking makes the framework fully independent of the source host', () => { @@ -202,7 +243,7 @@ describe('mintForkedIdentifiers', () => { p.CFItems[0].CFItemTypeURI = { identifier: 'itemtype-1', uri: 'https://source.example.org/ims/case/v1p1/CFItemTypes/itemtype-1', title: 'Standard' } p.CFAssociations[0].CFAssociationGroupingURI = { identifier: 'grouping-1', uri: 'https://source.example.org/ims/case/v1p1/CFAssociationGroupings/grouping-1', title: 'Alignment' } - const minted = mintForkedIdentifiers(p, tenantId, caseVersion) + const { bundle: minted } = mintForkedIdentifiers(p, tenantId, caseVersion) expect((minted.CFDocument as any).licenseURI).toEqual({ identifier: 'license-1', uri: '/ims/case/v1p1/CFLicenses/license-1', title: 'CC BY' }) expect((minted.CFItems![0] as any).CFItemTypeURI).toEqual({ identifier: 'itemtype-1', uri: '/ims/case/v1p1/CFItemTypes/itemtype-1', title: 'Standard' }) diff --git a/apps/opencase/src/application/case/services/mirrorFork.ts b/apps/opencase/src/application/case/services/mirrorFork.ts index cb9eab5..d7378bf 100644 --- a/apps/opencase/src/application/case/services/mirrorFork.ts +++ b/apps/opencase/src/application/case/services/mirrorFork.ts @@ -54,6 +54,12 @@ function basePath (caseVersion: CaseVersion): string { * CFDefinitions preserved from its source. Comparing CFDefinitions raw * forked on the very first save of any mirror that referenced such a * definition, regardless of what the user actually edited. + * - The `title` of an association's `originNodeURI`/`destinationNodeURI` + * — a display label copied from the node it names, not part of the + * relationship. The frontend rebuilds it from the item on every save + * (e.g. a source's "MATH.313 Count on." comes back as "Count on."), so + * comparing it forked a mirror on its first save whatever was edited. + * Which nodes an association connects, and how, are still compared. * No other fields are excluded — remaining reference URIs on * CFDocument/CFItems/CFAssociations themselves (licenseURI, CFItemTypeURI, * etc.) are stable given the framework id and don't produce false positives. @@ -71,10 +77,20 @@ export function stripNonDataFields (bundle: RawComparableBundle): RawComparableB return rest } + const stripNodeTitles = (association: any): any => { + if (!association || typeof association !== 'object') return association + const withoutTitle = (node: any): any => { + if (!node || typeof node !== 'object') return node + const { title: _title, ...rest } = node + return rest + } + return { ...association, originNodeURI: withoutTitle(association.originNodeURI), destinationNodeURI: withoutTitle(association.destinationNodeURI) } + } + return { CFDocument: stripEntity(bundle.CFDocument), CFItems: (bundle.CFItems ?? []).map(stripEntity), - CFAssociations: (bundle.CFAssociations ?? []).map(stripEntity), + CFAssociations: (bundle.CFAssociations ?? []).map((a: any) => stripNodeTitles(stripEntity(a))), CFRubrics: (bundle.CFRubrics ?? []).map(stripEntity), CFDefinitions: null } @@ -96,6 +112,11 @@ export function stripNonDataFields (bundle: RawComparableBundle): RawComparableB * this tenant's local catalog at import time and a fork is meant to be a * fully independent local copy, no longer pointing back at the source host. * + * Association references to nodes in other frameworks are left unchanged. + * + * Also returns each old document and item identifier's new one, so callers + * can update anything outside the package that refers to them. + * * The document's own identifier changes here, but its storage location does * not — see FileCFPackageRepository.saveNewVersion's storageKey parameter. */ @@ -103,7 +124,7 @@ export function mintForkedIdentifiers ( payload: RawComparableBundle, tenantId: TenantId, caseVersion: CaseVersion -): RawComparableBundle { +): { bundle: RawComparableBundle, forkedIdentifiers: Record } { const base = basePath(caseVersion) const cloned: RawComparableBundle = JSON.parse(JSON.stringify(payload)) @@ -149,15 +170,18 @@ export function mintForkedIdentifiers ( } } + // A reference to a node in another framework keeps its identifier and URI: + // only this framework is being forked. const resolveNode = (ref: any, label: string): { title: string, identifier: string, uri: string } => { if (oldDocId !== undefined && ref?.identifier === oldDocId) { return { title: ref?.title ?? newDocTitle, identifier: newDocId, uri: newDocUri } } const oldId = ref?.identifier as string | undefined - const newId = oldId ? nodeIdMap.get(oldId) : undefined - if (!newId) { - throw new Error(`Fork failed: ${label} references unknown node '${oldId ?? '(missing)'}'`) + if (!oldId) { + throw new Error(`Fork failed: ${label} has no identifier`) } + const newId = nodeIdMap.get(oldId) + if (!newId) return ref return { title: ref?.title ?? newId, identifier: newId, uri: `${base}/CFItems/${newId}` } } @@ -181,5 +205,5 @@ export function mintForkedIdentifiers ( rubric.uri = `${base}/CFRubrics/${newRubricId}` } - return cloned + return { bundle: cloned, forkedIdentifiers: Object.fromEntries(nodeIdMap) } } diff --git a/apps/opencase/src/interfaces/http/http-management/controllers/CFItemsManagementController.ts b/apps/opencase/src/interfaces/http/http-management/controllers/CFItemsManagementController.ts index e20bad4..1e17e04 100644 --- a/apps/opencase/src/interfaces/http/http-management/controllers/CFItemsManagementController.ts +++ b/apps/opencase/src/interfaces/http/http-management/controllers/CFItemsManagementController.ts @@ -2,15 +2,42 @@ import { type Request, type Response } from 'express' import { type UpdateCFItem } from '../../../../application/case/endpoints/UpdateCFItem' import { type DeleteCFItem } from '../../../../application/case/endpoints/DeleteCFItem' +import { type LookupItemDocuments } from '../../../../application/case/endpoints/LookupItemDocuments' import { getParam } from '../../utils/expressParams' import { getCaseVersion } from '../../utils/caseVersion' export class CFItemsManagementController { constructor ( private readonly updateCFItem: UpdateCFItem, - private readonly deleteCFItem: DeleteCFItem + private readonly deleteCFItem: DeleteCFItem, + private readonly lookupItemDocuments: LookupItemDocuments ) {} + /** Upper bound on identifiers per lookup request */ + static readonly MAX_LOOKUP_IDENTIFIERS = 10000 + + lookupDocuments = async (req: Request, res: Response) => { + const tenantId = (req as any).tenantId ?? 'demo' + const urlTenantId = getParam(req, 'tenantId') + const caseVersion = getCaseVersion(req, { default: '1.1' })! + + // Verify tenant from JWT matches URL parameter + if (urlTenantId && urlTenantId !== tenantId) { + return res.status(403).json({ error: 'Tenant mismatch - authenticated tenant does not match URL parameter' }) + } + + const identifiers = req.body?.identifiers + if (!Array.isArray(identifiers) || !identifiers.every(id => typeof id === 'string')) { + return res.status(400).json({ error: 'identifiers must be an array of strings' }) + } + if (identifiers.length > CFItemsManagementController.MAX_LOOKUP_IDENTIFIERS) { + return res.status(400).json({ error: `At most ${CFItemsManagementController.MAX_LOOKUP_IDENTIFIERS} identifiers per request` }) + } + + const documents = this.lookupItemDocuments.execute({ tenantId, caseVersion, identifiers }) + res.status(200).json({ documents }) + } + update = async (req: Request, res: Response) => { try { const tenantId = (req as any).tenantId ?? 'demo' diff --git a/apps/opencase/src/interfaces/http/http-management/controllers/__tests__/CFItemsManagementController.test.ts b/apps/opencase/src/interfaces/http/http-management/controllers/__tests__/CFItemsManagementController.test.ts index 70a1ff0..690abdd 100644 --- a/apps/opencase/src/interfaces/http/http-management/controllers/__tests__/CFItemsManagementController.test.ts +++ b/apps/opencase/src/interfaces/http/http-management/controllers/__tests__/CFItemsManagementController.test.ts @@ -2,11 +2,13 @@ import { Request, Response } from 'express' import { CFItemsManagementController } from '../CFItemsManagementController' import { UpdateCFItem } from '../../../../../application/case/endpoints/UpdateCFItem' import { DeleteCFItem } from '../../../../../application/case/endpoints/DeleteCFItem' +import { LookupItemDocuments } from '../../../../../application/case/endpoints/LookupItemDocuments' describe('CFItemsManagementController', () => { let controller: CFItemsManagementController let mockUpdateCFItem: jest.Mocked let mockDeleteCFItem: jest.Mocked + let mockLookupItemDocuments: jest.Mocked let mockRequest: Partial let mockResponse: Partial let responseJson: jest.Mock @@ -21,9 +23,14 @@ describe('CFItemsManagementController', () => { execute: jest.fn().mockResolvedValue(undefined) } as any + mockLookupItemDocuments = { + execute: jest.fn().mockReturnValue({}) + } as any + controller = new CFItemsManagementController( mockUpdateCFItem, - mockDeleteCFItem + mockDeleteCFItem, + mockLookupItemDocuments ) responseJson = jest.fn() @@ -127,5 +134,61 @@ describe('CFItemsManagementController', () => { }) }) }) -}) + describe('lookupDocuments', () => { + beforeEach(() => { + mockRequest.params = { tenantId: 'test-tenant' } + ;(mockRequest as any).tenantId = 'test-tenant' + }) + + it('should return the documents that hold the requested items', async () => { + const documents = { 'item-1': { identifier: 'doc-1', title: 'Framework 1' } } + mockLookupItemDocuments.execute.mockReturnValue(documents) + mockRequest.body = { identifiers: ['item-1', 'item-2'] } + + await controller.lookupDocuments(mockRequest as Request, mockResponse as Response) + + expect(mockLookupItemDocuments.execute).toHaveBeenCalledWith({ + tenantId: 'test-tenant', + caseVersion: '1.1', + identifiers: ['item-1', 'item-2'] + }) + expect(responseStatus).toHaveBeenCalledWith(200) + expect(responseJson).toHaveBeenCalledWith({ documents }) + }) + + it('should return 403 when tenant mismatch', async () => { + ;(mockRequest as any).tenantId = 'different-tenant' + mockRequest.body = { identifiers: ['item-1'] } + + await controller.lookupDocuments(mockRequest as Request, mockResponse as Response) + + expect(responseStatus).toHaveBeenCalledWith(403) + expect(mockLookupItemDocuments.execute).not.toHaveBeenCalled() + }) + + it.each([ + ['missing', {}], + ['not an array', { identifiers: 'item-1' }], + ['containing non-strings', { identifiers: ['item-1', 42] }] + ])('should return 400 when identifiers is %s', async (_label, body) => { + mockRequest.body = body + + await controller.lookupDocuments(mockRequest as Request, mockResponse as Response) + + expect(responseStatus).toHaveBeenCalledWith(400) + expect(mockLookupItemDocuments.execute).not.toHaveBeenCalled() + }) + + it('should return 400 when too many identifiers are requested', async () => { + mockRequest.body = { + identifiers: Array.from({ length: CFItemsManagementController.MAX_LOOKUP_IDENTIFIERS + 1 }, (_, i) => `item-${i}`) + } + + await controller.lookupDocuments(mockRequest as Request, mockResponse as Response) + + expect(responseStatus).toHaveBeenCalledWith(400) + expect(mockLookupItemDocuments.execute).not.toHaveBeenCalled() + }) + }) +}) diff --git a/apps/opencase/src/interfaces/http/http-management/routes.ts b/apps/opencase/src/interfaces/http/http-management/routes.ts index 2db1bc5..1a420ff 100644 --- a/apps/opencase/src/interfaces/http/http-management/routes.ts +++ b/apps/opencase/src/interfaces/http/http-management/routes.ts @@ -142,6 +142,26 @@ export interface ManagementDeps { * responses: * 200: { description: Deleted } * + * /management/tenants/{tenantId}/ims/case/v1p0/CFItems/lookup-documents: + * post: + * operationId: lookupItemDocumentsV1p0 + * summary: Resolve item identifiers to the hosted CFDocuments that hold them (non-CASE extension) (CASE v1p0) + * tags: [ItemsManager] + * parameters: + * - { name: tenantId, in: path, required: true, schema: { type: string } } + * responses: + * 200: { description: "{ documents: { [itemId]: { identifier, title } } } — identifiers not on this server are omitted" } + * + * /management/tenants/{tenantId}/ims/case/v1p1/CFItems/lookup-documents: + * post: + * operationId: lookupItemDocumentsV1p1 + * summary: Resolve item identifiers to the hosted CFDocuments that hold them (non-CASE extension) (CASE v1p1) + * tags: [ItemsManager] + * parameters: + * - { name: tenantId, in: path, required: true, schema: { type: string } } + * responses: + * 200: { description: "{ documents: { [itemId]: { identifier, title } } } — identifiers not on this server are omitted" } + * * /management/tenants/{tenantId}/ims/case/v1p0/CFAssociations/{id}: * put: * operationId: updateCFAssociationV1p0 @@ -326,6 +346,16 @@ export function registerManagementRoutes (app: Express, deps: ManagementDeps): v requireScope('case.write'), withCaseVersion('1.1', deps.cfItemsController.delete as unknown as RequestHandler) ) + app.post( + '/management/tenants/:tenantId/ims/case/v1p0/CFItems/lookup-documents', + requireScope('case.read'), + withCaseVersion('1.0', deps.cfItemsController.lookupDocuments as unknown as RequestHandler) + ) + app.post( + '/management/tenants/:tenantId/ims/case/v1p1/CFItems/lookup-documents', + requireScope('case.read'), + withCaseVersion('1.1', deps.cfItemsController.lookupDocuments as unknown as RequestHandler) + ) app.put( '/management/tenants/:tenantId/ims/case/v1p0/CFAssociations/:id', diff --git a/apps/opencase/src/wiring/container.ts b/apps/opencase/src/wiring/container.ts index 034d444..2cc06a3 100644 --- a/apps/opencase/src/wiring/container.ts +++ b/apps/opencase/src/wiring/container.ts @@ -27,6 +27,7 @@ import { UpdateCFAssociation } from '../application/case/endpoints/UpdateCFAssoc import { DeleteCFDocument } from '../application/case/endpoints/DeleteCFDocument' import { RestoreFramework } from '../application/case/endpoints/RestoreFramework' import { DeleteCFItem } from '../application/case/endpoints/DeleteCFItem' +import { LookupItemDocuments } from '../application/case/endpoints/LookupItemDocuments' import { DeleteCFAssociation } from '../application/case/endpoints/DeleteCFAssociation' import { CFPackagesControllerV1p0 } from '../interfaces/http/http-public/v1p0/controllers/CFPackagesController' import { CFDocumentsControllerV1p0 } from '../interfaces/http/http-public/v1p0/controllers/CFDocumentsController' @@ -284,6 +285,7 @@ export async function buildContainer(): Promise { const updateCFAssociation = new UpdateCFAssociation(pkgRepo, store, jsonSchemaValidator) const deleteCFDocument = new DeleteCFDocument(pkgRepo, store) const deleteCFItem = new DeleteCFItem(pkgRepo, store) + const lookupItemDocuments = new LookupItemDocuments(store) const deleteCFAssociation = new DeleteCFAssociation(pkgRepo, store) const cfPackagesControllerV1p0 = new CFPackagesControllerV1p0(getCFPackage, store) @@ -370,7 +372,8 @@ export async function buildContainer(): Promise { ) const cfItemsManagementController = new CFItemsManagementController( updateCFItem, - deleteCFItem + deleteCFItem, + lookupItemDocuments ) const cfAssociationsManagementController = new CFAssociationsManagementController( updateCFAssociation,