diff --git a/src/app/services/files/file-download.ts b/src/app/services/files/file-download.ts new file mode 100644 index 0000000..1f0f32c --- /dev/null +++ b/src/app/services/files/file-download.ts @@ -0,0 +1,29 @@ +import { Injectable } from '@angular/core'; + +/** + * Hands a file to the browser. + * + * A service rather than a bare function because this is the only part of the export that touches + * the DOM: as a service a spec replaces it with a stub instead of having to fake `document`. + */ +@Injectable({ providedIn: 'root' }) +export class FileDownload { + + saveJson(fileName: string, payload: unknown) { + this.save(fileName, JSON.stringify(payload, null, 2), 'application/json'); + } + + private save(fileName: string, content: string, mimeType: string) { + const url = URL.createObjectURL(new Blob([content], { type: mimeType })); + const anchor = document.createElement('a'); + anchor.href = url; + anchor.download = fileName; + anchor.style.display = 'none'; + document.body.appendChild(anchor); + anchor.click(); + anchor.remove(); + // Revoked, or the blob is held for the life of the page - but not in the same tick: some + // browsers abort a save whose URL is revoked before they have finished reading it. + setTimeout(() => URL.revokeObjectURL(url), 0); + } +} diff --git a/src/app/services/flows/flow-transfer.spec.ts b/src/app/services/flows/flow-transfer.spec.ts new file mode 100644 index 0000000..3eb95c3 --- /dev/null +++ b/src/app/services/flows/flow-transfer.spec.ts @@ -0,0 +1,208 @@ +import { Flow, FlowData } from '@models/flow'; + +import { + buildFlowExport, + collectNodeTypeNames, + flowExportFileName, + parseFlowImport, + resolveLegacyTypeName +} from './flow-transfer'; + +function makeFlowData(overrides: Partial = {}): FlowData { + return { + blocks: [], + containers: [], + connections: [], + dependencies: [], + globalInputs: [], + lanes: [], + ...overrides + }; +} + +function makeFlow(overrides: Partial = {}): Flow { + return { + id: 'flow-1', + name: 'Bias rerun', + description: 'Re-runs the interview flow', + visibility: 'PRIVATE', + data: makeFlowData(), + author: 'lucio.lelii', + status: 'DRAFT', + createdAt: new Date('2026-01-01T00:00:00.000Z'), + updatedAt: new Date('2026-01-02T00:00:00.000Z'), + published: true, + finalized: true, + projectId: 'project-9', + ...overrides + } as Flow; +} + +describe('flow transfer', () => { + describe('the exported envelope', () => { + it('carries the graph, the name and the description', () => { + const data = makeFlowData({ blocks: [{ id: 'b1' } as any] }); + const file = buildFlowExport(makeFlow({ data }), new Date('2026-09-04T10:00:00.000Z')); + + expect(file).toEqual({ + formatVersion: 1, + exportedAt: '2026-09-04T10:00:00.000Z', + name: 'Bias rerun', + description: 'Re-runs the interview flow', + flow: data + }); + }); + + it('leaves behind everything the importing server decides for itself', () => { + // Not a style preference: an exported id invites mistaking an import for an update, and + // published/finalized/projectId belong to one installation and one user, not to the graph. + const file = buildFlowExport(makeFlow()) as unknown as Record; + + for (const key of ['id', 'author', 'owner', 'published', 'finalized', 'projectId', 'status', + 'createdAt', 'updatedAt', 'validationErrors']) { + expect(key in file).toBe(false); + } + }); + + it('keeps the node ids as they are', () => { + // Each flow stores its own graph, so ids never collide across flows - and rewriting them + // would mean remapping every connection and dependency that points at them. + const data = makeFlowData({ + blocks: [{ id: 'block-42' } as any], + connections: [{ id: 'c1', sourceId: 'block-42', targetId: 'block-43' } as any] + }); + + const file = buildFlowExport(makeFlow({ data })); + + expect(file.flow.blocks[0].id).toBe('block-42'); + expect(file.flow.connections[0].sourceId).toBe('block-42'); + }); + + it('turns the flow name into a readable file name', () => { + expect(flowExportFileName('Bias rerun')).toBe('bias-rerun.flow.json'); + expect(flowExportFileName('Analisi perĂ² / 2')).toBe('analisi-pero-2.flow.json'); + expect(flowExportFileName('***')).toBe('flow.flow.json'); + }); + }); + + describe('reading a file back', () => { + it('round trips an exported flow, subflows included', () => { + // A container's subflow lives inside its specificConfiguration, so it has to survive the + // trip without any code of its own. This is the test that says so. + const data = makeFlowData({ + blocks: [{ id: 'b1', typeName: 'LLMBlock' } as any], + containers: [{ + id: 'c1', + typeName: 'IteratorContainer', + specificConfiguration: { subFlow: { blocks: [{ id: 'inner' }] } } + } as any] + }); + + const file = buildFlowExport(makeFlow({ data })); + const parsed = parseFlowImport(JSON.stringify(file), 'ignored'); + + expect(parsed.ok).toBe(true); + if (!parsed.ok) return; + expect(parsed.name).toBe('Bias rerun'); + expect(parsed.description).toBe('Re-runs the interview flow'); + expect(parsed.data).toEqual(data); + expect((parsed.data.containers[0] as any).specificConfiguration.subFlow.blocks[0].id).toBe('inner'); + }); + + it('accepts a bare graph, naming it after the file', () => { + // The backend hands whole graphs around in this shape - the container import retriever does + // exactly that - so a JSON copied from there still imports. + const parsed = parseFlowImport(JSON.stringify({ blocks: [{ id: 'b1' }] }), 'copied-graph'); + + expect(parsed.ok).toBe(true); + if (!parsed.ok) return; + expect(parsed.name).toBe('copied-graph'); + expect(parsed.data.blocks).toHaveLength(1); + expect(parsed.data.lanes).toEqual([]); + }); + + it('fills in the collections a hand-written file leaves out', () => { + const parsed = parseFlowImport(JSON.stringify({ formatVersion: 1, name: 'Small', flow: { blocks: [] } }), 'x'); + + expect(parsed.ok).toBe(true); + if (!parsed.ok) return; + expect(parsed.data).toEqual({ + blocks: [], containers: [], connections: [], dependencies: [], globalInputs: [], lanes: [] + }); + }); + + it('rejects malformed JSON', () => { + const parsed = parseFlowImport('{ "blocks": [', 'x'); + + expect(parsed.ok).toBe(false); + if (parsed.ok) return; + expect(parsed.message).toContain('not valid JSON'); + }); + + it('rejects JSON that is not a flow', () => { + expect(parseFlowImport('[1, 2, 3]', 'x').ok).toBe(false); + expect(parseFlowImport('{"hello":"world"}', 'x').ok).toBe(false); + expect(parseFlowImport('{"formatVersion":1,"name":"n"}', 'x').ok).toBe(false); + }); + + it('refuses a file from a newer format instead of guessing', () => { + const parsed = parseFlowImport(JSON.stringify({ formatVersion: 2, flow: { blocks: [] } }), 'x'); + + expect(parsed.ok).toBe(false); + if (parsed.ok) return; + expect(parsed.message).toContain('newer version'); + }); + + it('falls back to the file name when the envelope has no usable name', () => { + const parsed = parseFlowImport(JSON.stringify({ formatVersion: 1, name: ' ', flow: { blocks: [] } }), 'from-disk'); + + expect(parsed.ok).toBe(true); + if (!parsed.ok) return; + expect(parsed.name).toBe('from-disk'); + }); + }); + + describe('the type names a file refers to', () => { + it('reaches the nodes inside a container subflow', () => { + // A type this server does not know is just as fatal inside a subflow as at the top level. + const data = makeFlowData({ + blocks: [{ typeName: 'LLMBlock' } as any], + containers: [{ + id: 'c1', + name: 'Loop', + typeName: 'IteratorContainer', + specificConfiguration: { + subFlow: makeFlowData({ blocks: [{ typeName: 'MysteryBlock' } as any] }) + } + } as any] + }); + + expect(collectNodeTypeNames(data).blocks).toEqual(['LLMBlock', 'MysteryBlock']); + }); + + it('maps the two type names the server has renamed', () => { + // BlockTypes.get still answers to both, so refusing them client-side would reject a file + // the server would have accepted. + expect(resolveLegacyTypeName('ChatHumanInteraction')).toBe('ChatInteraction'); + expect(resolveLegacyTypeName('ExclusiveMergeBlock')).toBe('BranchRejoinBlock'); + expect(resolveLegacyTypeName('LLMBlock')).toBe('LLMBlock'); + }); + + it('lists each block and container type once', () => { + const data = makeFlowData({ + blocks: [ + { typeName: 'LLMBlock' } as any, + { typeName: 'LLMBlock' } as any, + { typeName: 'EndBlock' } as any, + { } as any + ], + containers: [{ typeName: 'IteratorContainer' } as any] + }); + + expect(collectNodeTypeNames(data)).toEqual({ + blocks: ['LLMBlock', 'EndBlock'], + containers: ['IteratorContainer'] + }); + }); + }); +}); diff --git a/src/app/services/flows/flow-transfer.ts b/src/app/services/flows/flow-transfer.ts new file mode 100644 index 0000000..254dddd --- /dev/null +++ b/src/app/services/flows/flow-transfer.ts @@ -0,0 +1,191 @@ +import { Flow, FlowData } from '@models/flow'; +import { listFlowSubflows, resolveFlowSubflow } from '@utilities/flow-subflows'; + +/** + * Carrying a flow in and out of the app as a file. + * + * Pure on purpose: no DOM, no services, no HTTP. Saving the file and creating the flow are the + * caller's job, which keeps the format itself testable without a TestBed. + */ + +export const FLOW_EXPORT_FORMAT_VERSION = 1; + +/** + * The exported envelope. + * + * Not the bare `FlowData`, so the name and description survive the trip and there is somewhere to + * put the version. The shape mirrors the `ImportedFlow` record the backend seed importer already + * reads from its own `flows.json`. + */ +export type FlowExportFile = { + formatVersion: number; + exportedAt: string; + name: string; + description: string; + flow: FlowData; +}; + +export type FlowImportParse = + | { ok: true; name: string; description: string; data: FlowData } + | { ok: false; message: string }; + +/** + * What travels is the graph and its name. Everything else is deliberately left behind: + * + * - `id`, `createdAt`, `updatedAt`: the server assigns its own on import, and carrying an id + * invites mistaking an import for an update; + * - `author`: whoever imports the file owns the result; + * - `published` and `finalized`: visibility and lifecycle are not part of the content, so an + * imported flow starts as an editable draft; + * - `projectId`: projects belong to one user; + * - `status` and `validationErrors`: the server derives both from the graph. + * + * Node ids *inside* the graph are kept as they are: each flow stores its own graph, so they never + * collide, and rewriting them would mean remapping every connection and dependency. + */ +export function buildFlowExport( + flow: Pick, + now: Date = new Date() +): FlowExportFile { + return { + formatVersion: FLOW_EXPORT_FORMAT_VERSION, + exportedAt: now.toISOString(), + name: flow.name, + description: flow.description ?? '', + flow: flow.data + }; +} + +/** `Bias rerun` becomes `bias-rerun.flow.json`, so a folder of exports stays readable. */ +export function flowExportFileName(name: string): string { + const slug = (name ?? '') + .normalize('NFKD') + .replace(/[\u0300-\u036f]/g, '') + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') + .slice(0, 60); + return `${slug || 'flow'}.flow.json`; +} + +/** + * Reads a file back. + * + * Accepts a bare `FlowData` as well as an envelope: the backend hands whole graphs around in that + * shape - the container import retriever does exactly this - so a JSON copied from there, or out + * of the database, still imports. In that case the name comes from the file name, which is why the + * caller has to supply one. + */ +export function parseFlowImport(text: string, fallbackName: string): FlowImportParse { + let parsed: unknown; + try { + parsed = JSON.parse(text); + } catch { + return { ok: false, message: 'That file is not valid JSON.' }; + } + + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + return { ok: false, message: 'That file does not contain a flow.' }; + } + + const record = parsed as Record; + const version = record['formatVersion']; + + if (typeof version === 'number') { + if (version > FLOW_EXPORT_FORMAT_VERSION) { + return { + ok: false, + message: `That file was exported by a newer version of HumAIn Flow (format ${version}).` + }; + } + const data = toFlowData(record['flow']); + if (!data) return { ok: false, message: 'That file does not contain a flow.' }; + return { + ok: true, + name: readName(record['name']) ?? fallbackName, + description: typeof record['description'] === 'string' ? record['description'] : '', + data + }; + } + + // No envelope: accept the graph on its own. + const bare = toFlowData(record); + if (!bare) return { ok: false, message: 'That file does not contain a flow.' }; + return { ok: true, name: fallbackName, description: '', data: bare }; +} + +/** + * The node type names the file refers to, so a caller can check them against its catalogs. + * + * Containers carry their own graph inside their configuration, and a type this server does not + * know is just as fatal in there, so the walk goes all the way down - reusing the app's own + * definition of where a subflow lives rather than growing a second one. + */ +export function collectNodeTypeNames(data: FlowData): { blocks: string[]; containers: string[] } { + const blocks = new Set(); + const containers = new Set(); + + const collect = (graph: FlowData) => { + for (const name of typeNamesOf(graph.blocks)) blocks.add(name); + for (const name of typeNamesOf(graph.containers)) containers.add(name); + }; + + collect(data); + for (const entry of listFlowSubflows(data)) { + const nested = resolveFlowSubflow(data, entry.locator); + if (nested) collect(nested); + } + + return { blocks: [...blocks], containers: [...containers] }; +} + +/** + * The server still answers to two type names it has renamed (BlockTypes.get). A file written + * before those renames imports fine, so the client check has to know them too - otherwise it + * would refuse a flow the server would have accepted. + */ +const LEGACY_TYPE_NAMES: Readonly> = { + ChatHumanInteraction: 'ChatInteraction', + ExclusiveMergeBlock: 'BranchRejoinBlock' +}; + +export function resolveLegacyTypeName(typeName: string): string { + return LEGACY_TYPE_NAMES[typeName] ?? typeName; +} + +function typeNamesOf(nodes: ReadonlyArray<{ typeName?: string }> | undefined): string[] { + const found: string[] = []; + for (const node of nodes ?? []) { + const typeName = node?.typeName; + if (typeof typeName === 'string' && typeName.trim().length) found.push(typeName); + } + return found; +} + +/** + * A flow is recognised by having a `blocks` array. The other collections are normalised to empty + * rather than demanded: an older or hand-written file that omits `lanes` or `dependencies` is + * still a flow, and the editor treats those as empty anyway. + */ +function toFlowData(value: unknown): FlowData | null { + if (!value || typeof value !== 'object' || Array.isArray(value)) return null; + const record = value as Record; + if (!Array.isArray(record['blocks'])) return null; + + return { + blocks: record['blocks'] as FlowData['blocks'], + containers: asArray(record['containers']) as FlowData['containers'], + connections: asArray(record['connections']) as FlowData['connections'], + dependencies: asArray(record['dependencies']) as FlowData['dependencies'], + globalInputs: asArray(record['globalInputs']) as FlowData['globalInputs'], + lanes: asArray(record['lanes']) as FlowData['lanes'] + }; +} + +function asArray(value: unknown): unknown[] { + return Array.isArray(value) ? value : []; +} + +function readName(value: unknown): string | null { + return typeof value === 'string' && value.trim().length ? value.trim() : null; +} diff --git a/src/app/services/flows/flows.ts b/src/app/services/flows/flows.ts index 03a59ad..4092de7 100644 --- a/src/app/services/flows/flows.ts +++ b/src/app/services/flows/flows.ts @@ -1,6 +1,6 @@ import { Injectable, signal } from '@angular/core'; import { environment } from '@environment'; -import { Flow } from '@models/flow'; +import { Flow, FlowData } from '@models/flow'; import { FlowsCallServiceBase } from './flows-call.base'; import { catchError, firstValueFrom, Observable, of, switchMap, tap, throwError } from 'rxjs'; @@ -210,6 +210,28 @@ export class FlowsService { ); } + /** + * Creates a flow from a file the user picked. + * + * The name is de-duplicated the same way "Empty flow" does it, so importing the same file twice + * gives two flows you can tell apart. `status` is always DRAFT: the server derives the real one + * from the graph, so sending back an exported status would be inventing a fact. + */ + importFlow(imported: { name: string; description?: string; data: FlowData }): Observable { + return this.createFlow({ + name: this.nextFileName(imported.name), + description: imported.description, + data: imported.data, + status: 'DRAFT' + }).pipe( + tap(() => this.refresh()), + catchError(err => { + console.error('Importing flow failed', err); + return throwError(() => err); + }) + ); + } + createNewFlow(name? : string) { return this.createFlow({ name: name || this.nextFileName('New Flow'), diff --git a/src/app/shared/flows-list/flow-item/flow-item.html b/src/app/shared/flows-list/flow-item/flow-item.html index ece9503..570c5fe 100644 --- a/src/app/shared/flows-list/flow-item/flow-item.html +++ b/src/app/shared/flows-list/flow-item/flow-item.html @@ -40,6 +40,14 @@ Clone flow + + @if (projectsEnabled) {