diff --git a/.gitignore b/.gitignore index ecfe9c6..41968a9 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ AGENTS.md CLAUDE.md .DS_Store node_modules/ +*.tsbuildinfo diff --git a/apps/editor/src/app/App.tsx b/apps/editor/src/app/App.tsx index 7eeaf37..f5fdda9 100644 --- a/apps/editor/src/app/App.tsx +++ b/apps/editor/src/app/App.tsx @@ -126,6 +126,13 @@ function AppInner() { const [authCallbackState, setAuthCallbackState] = useState<'idle' | 'processing' | 'error'>('idle') const [remoteOpenState, setRemoteOpenState] = useState<'idle' | 'loading'>('idle') + // 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. + const [serverVerifiedIds, setServerVerifiedIds] = useState>(() => new Set()) + const markServerVerified = useCallback((id: string) => { + setServerVerifiedIds((prev) => (prev.has(id) ? prev : new Set(prev).add(id))) + }, []) const authStatusRef = useRef(authStatus) authStatusRef.current = authStatus @@ -298,10 +305,6 @@ function AppInner() { setScreen('home') }, []) - const openFramework = useCallback((id: string) => { - navigateToFramework(id) - }, [navigateToFramework]) - /** Remove a framework from localStorage (used after archive or hard delete) */ const removeFrameworkFromStorage = useCallback((docId: string) => { setFrameworks((prev) => { @@ -329,6 +332,7 @@ function AppInner() { const createNew = useCallback(async (draft: CreateFrameworkDraft) => { const fw = createNewFrameworkDraft(draft) if (tenantId) await saveNewFrameworkToServer(fw, tenantId) + markServerVerified(fw.id) setFrameworks((prev) => { const next = [fw, ...prev] @@ -336,12 +340,13 @@ function AppInner() { return next }) navigateToFramework(fw.id) - }, [navigateToFramework, tenantId, saveNewFrameworkToServer]) + }, [navigateToFramework, tenantId, saveNewFrameworkToServer, markServerVerified]) /** Create a HomeFramework from a pre-populated domain Framework (e.g. from spreadsheet upload). */ const createFromFramework = useCallback(async (framework: Framework) => { const fw = createHomeFrameworkFromDomain(framework) if (tenantId) await saveNewFrameworkToServer(fw, tenantId) + markServerVerified(fw.id) setFrameworks((prev) => { const next = [fw, ...prev] @@ -349,7 +354,7 @@ function AppInner() { return next }) navigateToFramework(fw.id) - }, [navigateToFramework, tenantId, saveNewFrameworkToServer]) + }, [navigateToFramework, tenantId, saveNewFrameworkToServer, markServerVerified]) const openRemoteFramework = useCallback( async (docId: string, opts?: { replace?: boolean }) => { @@ -384,6 +389,7 @@ function AppInner() { // Create a HomeFramework entry from the domain Framework const fw = createHomeFrameworkFromDomain(framework, mirrorStatus) + markServerVerified(fw.id) if (pkg.CFDocument?.extensions) { fw.cfDocument = { ...fw.cfDocument, extensions: pkg.CFDocument.extensions } } @@ -419,16 +425,17 @@ function AppInner() { setRemoteOpenState('idle') } }, - [api, mergeCfDefinitions, navigateToFramework], + [api, mergeCfDefinitions, navigateToFramework, markServerVerified], ) - // If the URL points at a framework that isn't in the local cache yet (e.g. a hard refresh, - // or a deep link to a framework never opened on this device), fetch it from the server. + // If the URL points at a framework not yet confirmed against the server this session (e.g. a + // hard refresh or deep link), fetch it from the server — even when it's in the local cache, + // since the cached copy may be stale or may never have been saved at all. // Falls back to home if it can't be loaded (deleted, no access, etc.). useEffect(() => { if (!activeFrameworkId) return if (authStatus !== 'authenticated') return - if (frameworks.some((f) => f.id === activeFrameworkId)) return + if (serverVerifiedIds.has(activeFrameworkId)) return let cancelled = false openRemoteFramework(activeFrameworkId, { replace: true }).catch((err: unknown) => { if (cancelled) return @@ -436,7 +443,7 @@ function AppInner() { navigateHome({ replace: true }) }) return () => { cancelled = true } - }, [activeFrameworkId, authStatus, frameworks, openRemoteFramework, navigateHome]) + }, [activeFrameworkId, authStatus, serverVerifiedIds, openRemoteFramework, navigateHome]) // Load a framework from the server into the local session without navigating to it. // Used by TreePanelView when the user selects a crosswalk target that isn't loaded locally yet. @@ -520,10 +527,11 @@ function AppInner() { removeFrameworkFromStorage(activeFrameworkId) }, [api, tenantId, activeFrameworkId, caseApiVersion, removeFrameworkFromStorage]) - // Handler to fetch the published CFPackage from the server (returns CASE JSON with absolute URIs) + // Handler to fetch the published CFPackage from the server (returns CASE JSON with absolute URIs). + // This is the user-facing view/export, so omit OpenCASE extensions (ext:opencase). const handleFetchCfPackage = useCallback(async () => { if (!activeFrameworkId) throw new Error('No active framework') - return api.getCfPackage({ docId: activeFrameworkId, caseVersion: caseApiVersion }) + return api.getCfPackage({ docId: activeFrameworkId, caseVersion: caseApiVersion, includeExtensions: false }) }, [api, activeFrameworkId, caseApiVersion]) // Handler to save the CFPackage to the server @@ -707,7 +715,6 @@ function AppInner() { const homeScreen = ( navigateHome()} onSaveToServer={tenantId ? handleSaveToServer : undefined} - // Every framework that reaches `frameworks` was either loaded from the server - // or saved to it before being added locally (see saveNewFrameworkToServer, - // openRemoteFramework, handleLoadTargetFramework, handleSaveToServer) — so if - // there's an active framework at all, it's known-published. - isPublishedToOpenCase={Boolean(activeFrameworkId)} onArchiveFramework={tenantId && activeFrameworkId ? handleArchiveFramework : undefined} onFetchCfPackage={activeFrameworkId ? handleFetchCfPackage : undefined} availableFrameworks={frameworks} diff --git a/apps/editor/src/infrastructure/caseApi/CaseApiClient.ts b/apps/editor/src/infrastructure/caseApi/CaseApiClient.ts index aea4646..7b05c4a 100644 --- a/apps/editor/src/infrastructure/caseApi/CaseApiClient.ts +++ b/apps/editor/src/infrastructure/caseApi/CaseApiClient.ts @@ -134,9 +134,12 @@ export class CaseApiClient { return [] } - async getCfPackage(params: { docId: string; caseVersion?: 'v1p0' | 'v1p1' }): Promise { + async getCfPackage(params: { docId: string; caseVersion?: 'v1p0' | 'v1p1'; includeExtensions?: boolean }): Promise { const v = params.caseVersion ?? 'v1p1' - const res = (await this._http.get(`/ims/case/${v}/CFPackages/${encodeURIComponent(params.docId)}`)) as unknown + const res = (await this._http.get( + `/ims/case/${v}/CFPackages/${encodeURIComponent(params.docId)}`, + { includeExtensions: params.includeExtensions }, + )) as unknown if (!res || typeof res !== 'object') throw new Error('Unexpected CFPackage response shape') if ('CFDocument' in res) { diff --git a/apps/editor/src/infrastructure/caseApi/http.ts b/apps/editor/src/infrastructure/caseApi/http.ts index c1dbfd6..b448553 100644 --- a/apps/editor/src/infrastructure/caseApi/http.ts +++ b/apps/editor/src/infrastructure/caseApi/http.ts @@ -1,5 +1,13 @@ +export type RequestOptions = { + /** + * Send X-CASE-EDITOR so OpenCASE includes its proprietary `extensions` (ext:opencase) data. + * Defaults to true; pass false for spec-clean CASE JSON (e.g. user-facing export). + */ + includeExtensions?: boolean +} + export type HttpClient = { - get: (_url: string) => Promise + get: (_url: string, _options?: RequestOptions) => Promise post: (_url: string, _body: unknown) => Promise put: (_url: string, _body: unknown) => Promise patch: (_url: string, _body: unknown) => Promise @@ -54,14 +62,13 @@ async function readBody(res: Response): Promise { } export function createFetchHttpClient(baseUrl: string, options: FetchHttpClientOptions = {}): HttpClient { - const doRequest = async (method: string, url: string, body?: unknown): Promise => { + const doRequest = async (method: string, url: string, body?: unknown, requestOptions: RequestOptions = {}): Promise => { const fullUrl = joinUrl(baseUrl, url) const token = options.getAccessToken ? await options.getAccessToken() : null - const headers: Record = { - // Required by OpenCASE to return OpenCASE extensions in responses - 'X-CASE-EDITOR': 'true', - } + const headers: Record = {} + // Required by OpenCASE to return OpenCASE extensions in responses + if (requestOptions.includeExtensions !== false) headers['X-CASE-EDITOR'] = 'true' if (token) headers.Authorization = `Bearer ${token}` if (body !== undefined) headers['Content-Type'] = 'application/json' @@ -81,7 +88,7 @@ export function createFetchHttpClient(baseUrl: string, options: FetchHttpClientO } return { - get: (url) => doRequest('GET', url), + get: (url, requestOptions) => doRequest('GET', url, undefined, requestOptions), post: (url, body) => doRequest('POST', url, body), put: (url, body) => doRequest('PUT', url, body), patch: (url, body) => doRequest('PATCH', url, body), diff --git a/apps/editor/src/ui/editor/EditorCanvas.tsx b/apps/editor/src/ui/editor/EditorCanvas.tsx index a30f27e..8690e77 100644 --- a/apps/editor/src/ui/editor/EditorCanvas.tsx +++ b/apps/editor/src/ui/editor/EditorCanvas.tsx @@ -35,7 +35,7 @@ import type { CFDocument, CFItem, CFPackage } from '@/domain/case/types' import type { HomeFramework } from '@/ui/home/frameworkStore' import { useAuth } from '@/app/providers/AuthProvider' import { fromEditorGraph } from '@/ui/editor/reactflow/mapping/fromEditorGraph' -import { absolutizeCaseUris, frameworkToCfPackage, toOpenCaseFormat } from '@/application/framework/mappers/case/toCasePackage' +import { frameworkToCfPackage, toOpenCaseFormat } from '@/application/framework/mappers/case/toCasePackage' import type { Framework } from '@/domain/framework/model/types' import { hasFrameworkDataChanged } from '@/domain/framework/hasFrameworkDataChanged' @@ -190,8 +190,6 @@ type MirrorStatus = { isModifiedFromSource?: boolean; sourcePackageURI?: string type EditorCanvasProps = { onBack?: () => void onSaveToServer?: (cfPackage: ReturnType, framework: Framework) => Promise - /** Whether the current framework has been published to OpenCASE (loaded from or saved to server) */ - isPublishedToOpenCase?: boolean /** 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) */ @@ -244,7 +242,7 @@ function RemoteLinkInternalsSync({ return null } -export default function EditorCanvas({ onBack, onSaveToServer, isPublishedToOpenCase, onArchiveFramework, onFetchCfPackage, availableFrameworks, serverFrameworks, onLoadTargetFramework, onSaveAlignments, onLoadAlignmentsForTarget, onDiscoverAlignedTargets, mirrorStatus }: Readonly) { +export default function EditorCanvas({ onBack, onSaveToServer, onArchiveFramework, onFetchCfPackage, availableFrameworks, serverFrameworks, onLoadTargetFramework, onSaveAlignments, onLoadAlignmentsForTarget, onDiscoverAlignedTargets, mirrorStatus }: Readonly) { const { status: authStatus, userName, tenantId, signOut, changePassword } = useAuth() const { nodes, @@ -398,34 +396,19 @@ export default function EditorCanvas({ onBack, onSaveToServer, isPublishedToOpen const saveCtxRef = useRef({ caseVersion, edgeType: settings.edgeType, cfItemTypes, cfSubjects, cfConcepts, cfLicenses, cfAssociationGroupings }) saveCtxRef.current = { caseVersion, edgeType: settings.edgeType, cfItemTypes, cfSubjects, cfConcepts, cfLicenses, cfAssociationGroupings } - // Open the CFPackage viewer. Fetches from the server when published (absolute URIs); - // falls back to local generation for unsaved/draft frameworks. + // Open the CFPackage viewer. Every framework open in the editor has been saved to the + // server, so the server copy (absolute URIs, no OpenCASE extensions) is the export. const handleViewCFPackage = useCallback(async () => { - if (isPublishedToOpenCase && onFetchCfPackage) { - setCfPackageDialogOpen(true) - setViewCaseLoading(true) - try { - const pkg = await onFetchCfPackage() - setGeneratedCfPackage(pkg) - } finally { - setViewCaseLoading(false) - } - } else { - const { nodes: n, edges: e, remoteLinks: rl } = graphRef.current - const ctx = saveCtxRef.current - const { framework, layout, remoteEditorData } = fromEditorGraph({ graph: { nodes: n, edges: e, remoteLinks: rl } }) - const cfPackage = frameworkToCfPackage({ - framework, layout, - caseVersion: ctx.caseVersion, edgeType: ctx.edgeType, - cfItemTypes: ctx.cfItemTypes, cfSubjects: ctx.cfSubjects, - cfConcepts: ctx.cfConcepts, cfLicenses: ctx.cfLicenses, cfAssociationGroupings: ctx.cfAssociationGroupings, - remoteEditorData, - }) - const caseJson = toOpenCaseFormat(cfPackage) - setGeneratedCfPackage(absolutizeCaseUris(caseJson, window.location.origin)) - setCfPackageDialogOpen(true) + if (!onFetchCfPackage) return + setCfPackageDialogOpen(true) + setViewCaseLoading(true) + try { + const pkg = await onFetchCfPackage() + setGeneratedCfPackage(pkg) + } finally { + setViewCaseLoading(false) } - }, [isPublishedToOpenCase, onFetchCfPackage]) + }, [onFetchCfPackage]) // Actually perform the save (network call). Split out from `handleSave` so // the fork-warning dialog can defer this until the user confirms. @@ -1780,7 +1763,6 @@ export default function EditorCanvas({ onBack, onSaveToServer, isPublishedToOpen availableFrameworks={availableFrameworks} serverFrameworks={serverFrameworks} onLoadTargetFramework={onLoadTargetFramework} - isSourcePublished={isPublishedToOpenCase} onSaveAlignments={onSaveAlignments} onLoadAlignmentsForTarget={onLoadAlignmentsForTarget} onDiscoverAlignedTargets={onDiscoverAlignedTargets} @@ -1822,7 +1804,6 @@ export default function EditorCanvas({ onBack, onSaveToServer, isPublishedToOpen onChangeNode={updateNodeData} hideColorBand={activeView === 'tree'} onViewCFPackage={handleViewCFPackage} - isPublishedToOpenCase={isPublishedToOpenCase} availableLicenses={availableLicenses} cfItemTypes={cfItemTypes} ensureCfItemType={ensureCfItemType} diff --git a/apps/editor/src/ui/editor/components/NodePropertiesPanel.tsx b/apps/editor/src/ui/editor/components/NodePropertiesPanel.tsx index da168d6..a0a8978 100644 --- a/apps/editor/src/ui/editor/components/NodePropertiesPanel.tsx +++ b/apps/editor/src/ui/editor/components/NodePropertiesPanel.tsx @@ -36,7 +36,6 @@ type Props = { onClose?: () => void onChangeNode?: (_nodeId: string, _patch: CaseEditorNodeDataPatch) => void onViewCFPackage?: () => void - isPublishedToOpenCase?: boolean availableLicenses?: CFLicense[] cfItemTypes?: CFItemType[] ensureCfItemType?: (_title: string) => CFItemType | null @@ -55,7 +54,7 @@ type Props = { } export default memo(function NodePropertiesPanel({ - node, onClose, onChangeNode, onViewCFPackage, isPublishedToOpenCase, availableLicenses, + node, onClose, onChangeNode, onViewCFPackage, availableLicenses, cfItemTypes = [], ensureCfItemType, cfSubjects = [], ensureCfSubject, cfConcepts = [], ensureCfConcept, remoteLinks = [], onRemoveRemoteLink, onUpdateRemoteLinkType, onRemoveRemoteFramework, onBrowseRemoteItems, onRefreshRemoteFramework, remoteFrameworkRefreshing, @@ -723,7 +722,7 @@ export default memo(function NodePropertiesPanel({ ) : null} {/* ── OpenCASE URL (framework only) ── */} - {isFramework && isPublishedToOpenCase && opencaseUrl ? ( + {isFramework && opencaseUrl ? (
/** Load a framework from the server into the local session (called when a server-only target is selected) */ onLoadTargetFramework?: (id: string) => Promise - /** Whether the source (left-panel) framework has been saved to the server. Alignment authoring requires stable server-assigned URIs. */ - isSourcePublished?: boolean /** Called with a serialized alignment CFPackage when the user saves pending associations. */ onSaveAlignments?: (cfPackage: unknown) => Promise /** Called when the user expands a target framework — returns any previously-saved alignment doc ID and associations for that pairing. */ @@ -174,7 +172,7 @@ function findNearestVisible( // ── Component ───────────────────────────────────────────────────────────── -export default function TreePanelView({ availableFrameworks = [], serverFrameworks = [], onLoadTargetFramework, isSourcePublished = false, onSaveAlignments, onLoadAlignmentsForTarget, onDiscoverAlignedTargets }: Props) { +export default function TreePanelView({ availableFrameworks = [], serverFrameworks = [], onLoadTargetFramework, onSaveAlignments, onLoadAlignmentsForTarget, onDiscoverAlignedTargets }: Props) { const { nodes, cfItems, @@ -890,7 +888,7 @@ export default function TreePanelView({ availableFrameworks = [], serverFramewor onToggleExpand={handleLeftToggleExpand} onSelect={handleSelect} onAddChild={handleAddChild} - isDraggable={Boolean(expandedTargetFramework) && isSourcePublished} + isDraggable={Boolean(expandedTargetFramework)} onDragStart={NOOP_DRAG_START} associationCounts={leftAssociationCounts} onBadgeClick={handleLeftBadgeClick} @@ -906,15 +904,6 @@ export default function TreePanelView({ availableFrameworks = [], serverFramewor )} />
- {/* Gate callout — shown only when a target is expanded but source hasn't been saved */} - {expandedTargetFramework && !isSourcePublished && ( -
-

- Save this framework first to enable alignment authoring. - Items need stable server-assigned URIs before associations can be created. -

-
- )} @@ -1004,7 +993,7 @@ export default function TreePanelView({ availableFrameworks = [], serverFramewor onBadgeClick={handleRightBadgeClick} /> - {isSourcePublished && onSaveAlignments && target.hasUnsavedChanges && ( + {onSaveAlignments && target.hasUnsavedChanges && (
{targetAssocCount > 0