Skip to content

Commit ec8b335

Browse files
committed
fix(app): wire export center dry run state
1 parent 8aff1f4 commit ec8b335

7 files changed

Lines changed: 405 additions & 0 deletions

File tree

Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
import { Injectable } from "@angular/core";
2+
3+
export const EXPORT_FORMATS = ["postman", "openapi", "insomnia", "bruno", "har", "curl"] as const;
4+
export type ExportFormat = (typeof EXPORT_FORMATS)[number];
5+
export type ExportChange = "NEW" | "UPDATE" | "UNCHANGED";
6+
export type ExportDiagnosticSeverity = "info" | "warning" | "error";
7+
8+
export interface ExportCapabilities {
9+
readonly format: ExportFormat;
10+
readonly label: string;
11+
readonly supported: boolean;
12+
readonly lossy: boolean;
13+
readonly counts: Readonly<Record<string, number>>;
14+
readonly note: string;
15+
}
16+
17+
export interface ExportDiagnostic {
18+
readonly code: string;
19+
readonly severity: ExportDiagnosticSeverity;
20+
readonly operationIds: readonly string[];
21+
readonly message: string;
22+
readonly suggestion: string;
23+
}
24+
25+
export interface ExportFilePreview {
26+
readonly path: string;
27+
readonly change: ExportChange;
28+
readonly overwriteRisk: boolean;
29+
readonly outsideWorkspace: boolean;
30+
readonly content: string;
31+
}
32+
33+
export interface ExportDryRun {
34+
readonly files: readonly ExportFilePreview[];
35+
readonly diagnostics: readonly ExportDiagnostic[];
36+
readonly canGenerate: boolean;
37+
readonly requiresOverwriteConfirmation: boolean;
38+
readonly requiresOutsideWorkspaceConfirmation: boolean;
39+
}
40+
41+
export interface ExportRequest {
42+
readonly formats: readonly ExportFormat[];
43+
readonly outputDirectory: string;
44+
readonly workspaceRoot: string;
45+
readonly endpointCount: number;
46+
readonly operations?: readonly ExportOperation[];
47+
readonly existingFiles?: readonly ExportExistingFile[];
48+
readonly overwriteConfirmed?: boolean;
49+
readonly outsideWorkspaceConfirmed?: boolean;
50+
}
51+
52+
export interface ExportOperation {
53+
readonly id: string;
54+
readonly method: string;
55+
readonly path: string;
56+
readonly description?: string;
57+
readonly diagnostics?: readonly ExportDiagnostic[];
58+
}
59+
60+
export interface ExportExistingFile {
61+
readonly path: string;
62+
readonly content: string;
63+
}
64+
65+
export interface ExportResult {
66+
readonly files: readonly ExportFilePreview[];
67+
readonly outputDirectory: string;
68+
readonly postmanInstalled: boolean;
69+
}
70+
71+
export interface ExportArtifactWriter {
72+
write(path: string, content: string): Promise<void>;
73+
}
74+
75+
export class MemoryExportArtifactWriter implements ExportArtifactWriter {
76+
readonly written = new Map<string, string>();
77+
78+
async write(path: string, content: string): Promise<void> {
79+
this.written.set(path, content);
80+
}
81+
}
82+
83+
const FORMAT_DETAILS: Readonly<Record<ExportFormat, Omit<ExportCapabilities, "format">>> = {
84+
postman: { label: "Postman", supported: true, lossy: false, counts: { collections: 1, operations: 0 }, note: "Collection and environment compatible with Postman." },
85+
openapi: { label: "OpenAPI 3.1", supported: true, lossy: false, counts: { documents: 1, operations: 0 }, note: "Canonical HTTP contract with schemas and responses." },
86+
insomnia: { label: "Insomnia v4", supported: true, lossy: true, counts: { exports: 1, operations: 0 }, note: "HTTP operations preserved; non-HTTP transports may be partial." },
87+
bruno: { label: "Bruno", supported: true, lossy: true, counts: { collections: 1, operations: 0 }, note: "Folder collection; generated requests preserve HTTP details." },
88+
har: { label: "HAR", supported: true, lossy: true, counts: { archives: 1, operations: 0 }, note: "Network archive; auth metadata can be lossy." },
89+
curl: { label: "cURL", supported: true, lossy: true, counts: { scripts: 1, operations: 0 }, note: "One shell command per HTTP operation." },
90+
};
91+
92+
@Injectable({ providedIn: "root" })
93+
export class ExportsClient {
94+
constructor(private readonly artifactWriter: ExportArtifactWriter = new MemoryExportArtifactWriter()) {}
95+
96+
capabilities(endpointCount: number): readonly ExportCapabilities[] {
97+
return EXPORT_FORMATS.map((format) => ({ format, ...FORMAT_DETAILS[format], counts: { ...FORMAT_DETAILS[format].counts, operations: endpointCount } }));
98+
}
99+
100+
dryRun(request: ExportRequest): ExportDryRun {
101+
const operations = request.operations ?? createOperations(request.endpointCount);
102+
const files = request.formats.map((format) => {
103+
const path = joinPath(request.outputDirectory, `tanit-${format}.${format === "curl" ? "sh" : "json"}`);
104+
const content = renderArtifact(format, operations);
105+
const existing = request.existingFiles?.find((file) => normalizePath(file.path) === normalizePath(path));
106+
const outsideWorkspace = !isContainedPath(request.workspaceRoot, request.outputDirectory);
107+
const change: ExportChange = existing === undefined ? "NEW" : existing.content === content ? "UNCHANGED" : "UPDATE";
108+
return { path, change, overwriteRisk: change === "UPDATE", outsideWorkspace, content };
109+
});
110+
const diagnostics: ExportDiagnostic[] = request.endpointCount === 0 ? [{
111+
code: "NO_ENDPOINTS",
112+
severity: "error",
113+
operationIds: [],
114+
message: "No endpoints are available for export.",
115+
suggestion: "Scan the project or adjust the endpoint filters before generating.",
116+
}] : operations.flatMap((operation) => operation.diagnostics ?? []);
117+
const requiresOverwriteConfirmation = files.some((file) => file.overwriteRisk) && request.overwriteConfirmed !== true;
118+
const requiresOutsideWorkspaceConfirmation = files.some((file) => file.outsideWorkspace) && request.outsideWorkspaceConfirmed !== true;
119+
return { files, diagnostics, canGenerate: diagnostics.every((item) => item.severity !== "error") && !requiresOverwriteConfirmation && !requiresOutsideWorkspaceConfirmation, requiresOverwriteConfirmation, requiresOutsideWorkspaceConfirmation };
120+
}
121+
122+
generate(request: ExportRequest): Promise<ExportResult> {
123+
const preview = this.dryRun(request);
124+
if (!preview.canGenerate) return Promise.reject(new Error("Export confirmation is required before generating."));
125+
return Promise.all(preview.files.map((file) => this.artifactWriter.write(file.path, file.content))).then(() => ({ files: preview.files, outputDirectory: request.outputDirectory, postmanInstalled: false }));
126+
}
127+
}
128+
129+
function createOperations(endpointCount: number): readonly ExportOperation[] {
130+
return Array.from({ length: Math.max(0, endpointCount) }, (_, index) => ({ id: `operation-${index + 1}`, method: "GET", path: `/operation-${index + 1}` }));
131+
}
132+
133+
function normalizePath(value: string): string {
134+
const segments: string[] = [];
135+
for (const segment of value.replaceAll("\\", "/").split("/")) {
136+
if (!segment || segment === ".") continue;
137+
if (segment === "..") segments.pop(); else segments.push(segment);
138+
}
139+
return `/${segments.join("/")}`;
140+
}
141+
142+
function joinPath(directory: string, filename: string): string {
143+
return `${normalizePath(directory)}/${filename}`;
144+
}
145+
146+
function isContainedPath(workspaceRoot: string, candidate: string): boolean {
147+
const root = normalizePath(workspaceRoot);
148+
const path = normalizePath(candidate);
149+
return path === root || path.startsWith(`${root}/`);
150+
}
151+
152+
function renderArtifact(format: ExportFormat, operations: readonly ExportOperation[]): string {
153+
if (format === "curl") return operations.map((operation) => `curl -X ${operation.method} "http://localhost${operation.path}"`).join("\n");
154+
if (format === "har") return JSON.stringify({ log: { version: "1.2", entries: operations.map((operation) => ({ request: { method: operation.method, url: `http://localhost${operation.path}` } })) } }, null, 2);
155+
if (format === "openapi") return JSON.stringify({ openapi: "3.1.0", info: { title: "Tanit export", version: "1.0.0" }, paths: Object.fromEntries(operations.map((operation) => [operation.path, { [operation.method.toLowerCase()]: { operationId: operation.id, responses: { "200": { description: "Success" } } } }])) }, null, 2);
156+
if (format === "postman") return JSON.stringify({ info: { name: "Tanit export", schema: "https://schema.getpostman.com/json/collection/v2.1.0/collection.json" }, item: operations.map((operation) => ({ name: operation.id, request: { method: operation.method, url: `http://localhost${operation.path}` } })) }, null, 2);
157+
if (format === "insomnia") return JSON.stringify({ _type: "export", __export_format: 4, resources: operations.map((operation) => ({ _type: "request", name: operation.id, method: operation.method, url: `http://localhost${operation.path}` })) }, null, 2);
158+
return JSON.stringify({ version: "1", name: "Tanit export", requests: operations.map((operation) => ({ name: operation.id, request: { method: operation.method, url: `http://localhost${operation.path}` } })) }, null, 2);
159+
}
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import { Injectable, computed, inject, signal } from "@angular/core";
2+
3+
import { EXPORT_FORMATS, ExportCapabilities, ExportDiagnostic, ExportDryRun, ExportExistingFile, ExportFormat, ExportOperation, ExportResult, ExportRequest, ExportsClient } from "../api/exports.client";
4+
5+
@Injectable({ providedIn: "root" })
6+
export class ExportsStore {
7+
private readonly client = inject(ExportsClient);
8+
readonly formats = signal<readonly ExportFormat[]>(["postman"]);
9+
readonly outputDirectory = signal("/workspace/exports");
10+
readonly workspaceRoot = signal("/workspace");
11+
readonly endpointCount = signal(12);
12+
readonly operations = signal<readonly ExportOperation[]>([]);
13+
readonly existingFiles = signal<readonly ExportExistingFile[]>([]);
14+
readonly overwriteConfirmed = signal(false);
15+
readonly outsideWorkspaceConfirmed = signal(false);
16+
readonly capabilities = computed<readonly ExportCapabilities[]>(() => this.client.capabilities(this.endpointCount()));
17+
readonly preview = computed<ExportDryRun>(() => this.client.dryRun(this.request()));
18+
readonly diagnostics = computed<readonly ExportDiagnostic[]>(() => this.preview().diagnostics);
19+
readonly result = signal<ExportResult | null>(null);
20+
readonly error = signal<string | null>(null);
21+
22+
toggleFormat(format: ExportFormat): void {
23+
const selected = new Set(this.formats());
24+
if (selected.has(format)) selected.delete(format); else selected.add(format);
25+
this.formats.set(EXPORT_FORMATS.filter((item) => selected.has(item)));
26+
this.result.set(null);
27+
}
28+
setOutputDirectory(path: string): void { this.outputDirectory.set(path); this.outsideWorkspaceConfirmed.set(false); this.result.set(null); }
29+
confirmOverwrite(value: boolean): void { this.overwriteConfirmed.set(value); }
30+
confirmOutsideWorkspace(value: boolean): void { this.outsideWorkspaceConfirmed.set(value); }
31+
setEndpointCount(count: number): void { this.endpointCount.set(count); }
32+
setOperations(operations: readonly ExportOperation[]): void { this.operations.set(operations); this.endpointCount.set(operations.length); this.result.set(null); }
33+
setExistingFiles(files: readonly ExportExistingFile[]): void { this.existingFiles.set(files); this.overwriteConfirmed.set(false); this.result.set(null); }
34+
async generate(): Promise<void> {
35+
this.error.set(null);
36+
try { this.result.set(await this.client.generate(this.request())); } catch (error) { this.error.set((error as Error).message); }
37+
}
38+
private request(): ExportRequest { return { formats: this.formats(), outputDirectory: this.outputDirectory(), workspaceRoot: this.workspaceRoot(), endpointCount: this.endpointCount(), operations: this.operations(), existingFiles: this.existingFiles(), overwriteConfirmed: this.overwriteConfirmed(), outsideWorkspaceConfirmed: this.outsideWorkspaceConfirmed() }; }
39+
}
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import { ChangeDetectionStrategy, Component, Injectable, inject } from "@angular/core";
2+
3+
import { DialogService } from "../../core/host/dialog.service";
4+
import { ExportDiagnostic, ExportFormat } from "../../core/api/exports.client";
5+
import { ExportsStore } from "../../core/state/exports.store";
6+
import { ExportPreviewComponent } from "./export-preview.component";
7+
import { ExportSummaryComponent } from "./export-summary.component";
8+
import { FormatCapabilitiesComponent } from "./format-capabilities.component";
9+
10+
@Component({
11+
selector: "tanit-export-center",
12+
standalone: true,
13+
imports: [ExportPreviewComponent, ExportSummaryComponent, FormatCapabilitiesComponent],
14+
changeDetection: ChangeDetectionStrategy.OnPush,
15+
template: `<section class="center"><header><div><span class="eyebrow">Export Center</span><h1>Generate API artifacts</h1><p>Choose formats, inspect the dry run, then confirm the destination.</p></div><button type="button" [disabled]="!store.preview().canGenerate" (click)="generate()">Generate</button></header><tanit-format-capabilities [capabilities]="store.capabilities()" [selected]="store.formats()" (changed)="toggleFormat($event)" /><section class="destination"><div><h2>Output directory</h2><code>{{ store.outputDirectory() }}</code>@if (store.preview().requiresOutsideWorkspaceConfirmation) { <p class="warning">This directory is outside the workspace.</p> }@if (store.preview().requiresOverwriteConfirmation) { <p class="warning">Existing files may be overwritten.</p> }</div><button type="button" (click)="chooseOutput()">Choose directory</button></section><label class="check"><input type="checkbox" [checked]="store.overwriteConfirmed()" (change)="store.confirmOverwrite($any($event.target).checked)" /> Confirm overwrite of existing files</label>@if (store.preview().requiresOutsideWorkspaceConfirmation) { <label class="check"><input type="checkbox" [checked]="store.outsideWorkspaceConfirmed()" (change)="store.confirmOutsideWorkspace($any($event.target).checked)" /> Confirm output outside workspace</label> }<tanit-export-preview [preview]="store.preview()" (diagnosticSelected)="selectDiagnostic($event)" /><tanit-export-summary [result]="store.result()" (openFolder)="openFolder($event)" (openPostman)="openPostman()" (copyPaths)="copyPaths($event)" />@if (store.error(); as error) { <p class="error" role="alert">{{ error }}</p> }</section>`,
16+
styles: `.center { display: grid; gap: 20px; max-width: 980px; } header, .destination { display: flex; justify-content: space-between; align-items: flex-start; gap: 16px; } h1 { margin: 5px 0; font-size: 32px; } h2 { margin: 0 0 5px; font-size: 18px; } p { margin: 0; color: var(--color-muted); } .eyebrow, .warning { color: var(--color-copper-strong); } header > button, .destination > button { border: 1px solid var(--color-copper); background: var(--color-copper); color: white; padding: 10px 14px; cursor: pointer; } header > button:disabled { opacity: .45; cursor: not-allowed; } .destination { padding: 14px 0; border-top: 1px solid var(--color-border); border-bottom: 1px solid var(--color-border); } code { overflow-wrap: anywhere; } .check { display: flex; align-items: center; gap: 8px; } .warning, .error { font-size: 13px; } .error { color: #a33; } @media (max-width: 640px) { header, .destination { display: grid; } }`,
17+
})
18+
export class ExportCenterComponent {
19+
readonly store = inject(ExportsStore);
20+
readonly dialog = inject(DialogService);
21+
readonly actions = inject(ExportSuccessActions);
22+
23+
toggleFormat(format: ExportFormat): void { this.store.toggleFormat(format); }
24+
async chooseOutput(): Promise<void> { const path = await this.dialog.pickFolder(); if (path) this.store.setOutputDirectory(path); }
25+
async generate(): Promise<void> { await this.store.generate(); }
26+
selectDiagnostic(diagnostic: ExportDiagnostic): void { if (typeof history !== "undefined") history.replaceState(null, "", `${location.pathname}?diagnosticCode=${encodeURIComponent(diagnostic.code)}`); }
27+
async openFolder(path: string): Promise<void> { await this.actions.openFolder(path); }
28+
async openPostman(): Promise<void> { await this.actions.openPostman(); }
29+
async copyPaths(paths: readonly string[]): Promise<void> { if (typeof navigator !== "undefined" && navigator.clipboard) await navigator.clipboard.writeText(paths.join("\n")); }
30+
}
31+
32+
export interface ExportSuccessActionsPort {
33+
openFolder(path: string): Promise<void>;
34+
openPostman(): Promise<void>;
35+
}
36+
37+
@Injectable({ providedIn: "root" })
38+
export class ExportSuccessActions implements ExportSuccessActionsPort {
39+
async openFolder(path: string): Promise<void> {
40+
if (typeof window !== "undefined") window.open(`tanit://open-folder?path=${encodeURIComponent(path)}`, "_blank");
41+
}
42+
43+
async openPostman(): Promise<void> {
44+
if (typeof window !== "undefined") window.open("tanit://open-postman", "_blank");
45+
}
46+
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from "@angular/core";
2+
3+
import { ExportDiagnostic, ExportDryRun } from "../../core/api/exports.client";
4+
5+
@Component({
6+
selector: "tanit-export-preview",
7+
standalone: true,
8+
changeDetection: ChangeDetectionStrategy.OnPush,
9+
template: `<section class="preview" aria-labelledby="preview-title"><header><div><h2 id="preview-title">Dry run</h2><p>Files and overwrite risk before generation.</p></div><strong>{{ preview.canGenerate ? "Ready" : "Needs confirmation" }}</strong></header><div class="files">@for (file of preview.files; track file.path) { <div class="file"><span>{{ file.change }}</span><code>{{ file.path }}</code>@if (file.overwriteRisk) { <small>Overwrite risk</small> } @if (file.outsideWorkspace) { <small>Outside workspace</small> }</div> }</div>@if (preview.diagnostics.length) { <div class="diagnostics" aria-live="polite">@for (diagnostic of preview.diagnostics; track diagnostic.code) { <button type="button" (click)="diagnosticSelected.emit(diagnostic)"><strong>{{ diagnostic.code }}</strong><span>{{ diagnostic.message }}</span><small>{{ diagnostic.suggestion }}</small></button> }</div> }</section>`,
10+
styles: `.preview { display: grid; gap: 12px; } header { display: flex; justify-content: space-between; gap: 12px; } h2 { margin: 0; font-size: 18px; } p { margin: 4px 0 0; color: var(--color-muted); } .files, .diagnostics { display: grid; gap: 6px; } .file { display: grid; grid-template-columns: 82px minmax(0, 1fr) auto; align-items: center; gap: 8px; padding: 9px 10px; border-bottom: 1px solid var(--color-border); } code { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } small { color: var(--color-muted); font-size: 11px; } .diagnostics button { display: grid; gap: 3px; padding: 9px; border: 1px solid var(--color-border); background: transparent; color: var(--color-ink); text-align: left; cursor: pointer; } .diagnostics span { color: var(--color-muted); }`,
11+
})
12+
export class ExportPreviewComponent {
13+
@Input() preview: ExportDryRun = { files: [], diagnostics: [], canGenerate: false, requiresOverwriteConfirmation: false, requiresOutsideWorkspaceConfirmation: false };
14+
@Output() readonly diagnosticSelected = new EventEmitter<ExportDiagnostic>();
15+
}

0 commit comments

Comments
 (0)