mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2026-09-03 05:10:16 +03:00
Define tool inputs & outputs in a structured way (#7204)
# Description of Changes Change tool APIs to use structured definitions for input/output/type info because we need that info to be able to validate whether policies can actually successfully work based on whether one tool accepts the output of another. There were various bugs in the previous string definitions because of either misspellings or just incorrect definitions, so I've gone through and fixed all that I can find. <img width="729" height="271" alt="image" src="https://github.com/user-attachments/assets/08357e96-6fbb-4b9c-ba4d-8995420c7b86" /> <img width="749" height="264" alt="image" src="https://github.com/user-attachments/assets/76f46284-1866-4b64-b1ed-2480e01866e9" /> <img width="402" height="636" alt="image" src="https://github.com/user-attachments/assets/8f7a36ca-2845-4f14-a2df-ec9c772e66f6" /> <img width="393" height="317" alt="image" src="https://github.com/user-attachments/assets/46d8b891-9820-4ce3-8109-a8b782277037" /> --------- Co-authored-by: Anthony Stirling <77850077+Frooodle@users.noreply.github.com>
This commit is contained in:
co-authored by
Anthony Stirling
parent
7a5eb73c5b
commit
cd199c8659
@@ -7740,6 +7740,7 @@ newPipeline = "New pipeline"
|
||||
[portal.pipelines.builder]
|
||||
addStep = "Add tool"
|
||||
back = "Back to pipelines"
|
||||
cannotFollow = "Can't take {{produced}}"
|
||||
chooseAccount = "Choose an account"
|
||||
chooseDestination = "Choose a destination"
|
||||
chooseOperation = "Choose what this step does"
|
||||
@@ -7759,6 +7760,7 @@ searchTools = "Search tools"
|
||||
selectToolBody = "Add a tool to build your pipeline."
|
||||
selectToolTitle = "No tools yet"
|
||||
sendToSystem = "Send to another system"
|
||||
stepsIncompatible = "These steps can't run on what their prior step produces: {{tools}}."
|
||||
stepsNeedSetup = "These steps still need setting up before saving: {{tools}}."
|
||||
toolSettings = "Tool settings"
|
||||
unknownStep = "Unrecognized operation, kept as-is."
|
||||
@@ -7767,6 +7769,14 @@ unsavedTitle = "Unsaved changes"
|
||||
uploadUnsupported = "Uploaded files aren't supported in pipelines yet, so these steps can't be saved: {{tools}}."
|
||||
usesDefaults = "Runs with default settings"
|
||||
|
||||
[portal.pipelines.builder.diagnostic]
|
||||
fan-in = "Combines every file from the previous step"
|
||||
fan-out = "Runs once per file from the previous step"
|
||||
format-mismatch = "Needs {{accepts}}, but the previous step produces {{produced}}"
|
||||
output-uncertain = "May not run: the previous step's output depends on how it's set up"
|
||||
source-mismatch = "Needs {{accepts}}, but this pipeline's input is {{produced}}"
|
||||
undeclared-operation = "Can't check what this step accepts"
|
||||
|
||||
[portal.pipelines.composer]
|
||||
addTool = "Add tool"
|
||||
cancel = "Cancel"
|
||||
@@ -10631,6 +10641,32 @@ selectFilesHint = "Select files in Active Files to run this tool"
|
||||
singleFileScope = "Only applying to: {{fileName}}"
|
||||
viewerMode = "Switch to the file editor to add multiple files."
|
||||
|
||||
[toolFormat]
|
||||
ANY = "any file"
|
||||
CBR = "a comic book archive (CBR)"
|
||||
CBZ = "a comic book archive (CBZ)"
|
||||
CSV = "a CSV file"
|
||||
EBOOK = "an ebook"
|
||||
EMAIL = "an email"
|
||||
EXCEL = "a spreadsheet"
|
||||
HTML = "a web page"
|
||||
IMAGE = "images"
|
||||
JAVASCRIPT = "a JavaScript file"
|
||||
JSON = "a JSON file"
|
||||
MARKDOWN = "a Markdown file"
|
||||
NONE = "no file"
|
||||
PCL = "a PCL print file"
|
||||
PDF = "a PDF"
|
||||
PDF_ENCRYPTED = "a password-protected PDF"
|
||||
POSTSCRIPT = "a PostScript file"
|
||||
PPT = "a presentation"
|
||||
TEXT = "a text file"
|
||||
VIDEO = "a video"
|
||||
WORD = "a Word document"
|
||||
XML = "an XML file"
|
||||
XPS = "an XPS document"
|
||||
ZIP = "a ZIP archive"
|
||||
|
||||
[toolPanel]
|
||||
alpha = "Alpha"
|
||||
backToAllTools = "Back to all tools"
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
/**
|
||||
* Generates the committed frontend tool API types (toolApiTypes.ts) from the
|
||||
* Java backend's OpenAPI spec, so the frontend's request shapes stay in step
|
||||
* with the backend.
|
||||
* Generates the committed frontend files derived from the Java OpenAPI spec:
|
||||
* toolApiTypes.ts (each tool's request shape) and toolIO.ts (what it accepts and
|
||||
* produces, from `@ToolIO` via the `x-stirling-io` extension).
|
||||
*
|
||||
* One pass over one spec, so the two cannot drift apart.
|
||||
*/
|
||||
|
||||
import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
|
||||
@@ -10,14 +12,20 @@ import { parseArgs } from "node:util";
|
||||
import { compile, type JSONSchema } from "json-schema-to-typescript";
|
||||
import * as prettier from "prettier";
|
||||
|
||||
// The API namespaces whose endpoints are real, callable tools. `/api/v1/filter/`
|
||||
// (pipeline-only) and `/api/v1/ai/tools/` (not in the spec) are intentionally
|
||||
// excluded. Extend this list when other namespaces become tools.
|
||||
// The API namespaces whose endpoints a pipeline can reference. `/api/v1/ai/tools/`
|
||||
// is absent from the spec, so it cannot appear here. Extend this list when other
|
||||
// namespaces become tools.
|
||||
//
|
||||
// `/api/v1/filter/` and `/api/v1/integration/` are included even though neither is a
|
||||
// user-facing tool: a stored pipeline can contain one, and ToolEndpoint keys the I/O
|
||||
// table, so leaving them out would stop a chain being checked past such a step.
|
||||
const ALLOWED_PATH_PREFIXES = [
|
||||
"/api/v1/general/",
|
||||
"/api/v1/misc/",
|
||||
"/api/v1/security/",
|
||||
"/api/v1/convert/",
|
||||
"/api/v1/filter/",
|
||||
"/api/v1/integration/",
|
||||
];
|
||||
|
||||
// File plumbing, not user parameters: `fileInput` is the uploaded document and
|
||||
@@ -38,12 +46,21 @@ const FILE_WRAPPER_COMPONENTS = new Set([
|
||||
|
||||
const COMPONENT_REF_PREFIX = "#/components/schemas/";
|
||||
|
||||
const FILE_HEADER = [
|
||||
"// AUTO-GENERATED FILE. DO NOT EDIT.",
|
||||
"// Generated by editor/scripts/generate-tool-api-types.mts from the Java OpenAPI spec",
|
||||
"// (SwaggerDoc.json). Regenerate with: task frontend:tool-models",
|
||||
const IO_EXTENSION = "x-stirling-io";
|
||||
const IO_VOCABULARY_EXTENSION = "x-stirling-io-vocabulary";
|
||||
|
||||
function fileHeader(...extra: string[]): string {
|
||||
return [
|
||||
"// AUTO-GENERATED FILE. DO NOT EDIT.",
|
||||
"// Generated by editor/scripts/generate-tool-api-types.mts from the Java OpenAPI spec",
|
||||
"// (SwaggerDoc.json). Regenerate with: task frontend:tool-models",
|
||||
...extra,
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
const FILE_HEADER = fileHeader(
|
||||
"// Tools that take only a file input have no parameters; their model is Record<string, never>.",
|
||||
].join("\n");
|
||||
);
|
||||
|
||||
type Json = Record<string, unknown>;
|
||||
|
||||
@@ -170,23 +187,164 @@ function computeRequired(schema: Json, properties: Json): string[] {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The `x-stirling-io` declaration for every endpoint that carries one, path-sorted.
|
||||
*
|
||||
* Restricted to `endpoints`, the paths that become {@link ToolEndpoint}: the table is typed by
|
||||
* that union, so a declaration outside it would not type-check. `dropped` is reported rather
|
||||
* than discarded silently, since a tool losing its declaration this way is invisible otherwise.
|
||||
*/
|
||||
function collectToolIO(
|
||||
paths: Json,
|
||||
endpoints: Set<string>,
|
||||
): { table: Record<string, unknown>; dropped: string[] } {
|
||||
const table: Record<string, unknown> = {};
|
||||
const dropped: string[] = [];
|
||||
for (const path of Object.keys(paths).sort()) {
|
||||
const pathItem = paths[path];
|
||||
if (!isObject(pathItem)) continue;
|
||||
for (const operation of Object.values(pathItem)) {
|
||||
if (isObject(operation) && operation[IO_EXTENSION]) {
|
||||
if (endpoints.has(path)) table[path] = operation[IO_EXTENSION];
|
||||
else dropped.push(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
return { table, dropped };
|
||||
}
|
||||
|
||||
async function renderToolIO(
|
||||
spec: Json,
|
||||
table: Record<string, unknown>,
|
||||
outputPath: string,
|
||||
): Promise<string> {
|
||||
if (Object.keys(table).length === 0) {
|
||||
throw new Error(
|
||||
`No ${IO_EXTENSION} declarations in the spec. The backend publishes these from @ToolIO; regenerate with 'task backend:swagger'.`,
|
||||
);
|
||||
}
|
||||
// Published separately: deriving the unions from the declarations present would shrink them
|
||||
// whenever an endpoint is disabled in a build.
|
||||
const vocabulary = spec[IO_VOCABULARY_EXTENSION];
|
||||
if (!isObject(vocabulary)) {
|
||||
throw new Error(
|
||||
`No ${IO_VOCABULARY_EXTENSION} in the spec; regenerate it from a current backend.`,
|
||||
);
|
||||
}
|
||||
const union = (values: string[]) =>
|
||||
values.map((v) => JSON.stringify(v)).join(" | ");
|
||||
const formats = vocabulary.formats as string[];
|
||||
|
||||
const body = `${fileHeader()}
|
||||
|
||||
/**
|
||||
* What each tool endpoint accepts and produces, so a chain can be checked while it is being
|
||||
* edited rather than by running it. Declared in Java with \`@ToolIO\`; see
|
||||
* \`utils/toolIOCompat\` for the compatibility rules that read this table.
|
||||
*/
|
||||
|
||||
import { type ToolEndpoint } from "@app/types/toolApiTypes";
|
||||
|
||||
/** The kind of file a tool consumes or produces. \`ANY\` accepts or produces anything; \`NONE\` means no file at all. */
|
||||
export type ToolFormat = ${union(formats)};
|
||||
|
||||
/** Every format, for iteration. */
|
||||
export const TOOL_FORMATS = ${JSON.stringify(formats)} as const satisfies readonly ToolFormat[];
|
||||
|
||||
/** How many files go in and come out. A multi-output tool returns its results zipped, and the caller unpacks them. */
|
||||
export type ToolArity = ${union(vocabulary.arities as string[])};
|
||||
|
||||
/** One condition on a request parameter, guarding a {@link ToolIOCase}. */
|
||||
export interface ToolIOWhen {
|
||||
param: string;
|
||||
matches: string[];
|
||||
}
|
||||
|
||||
/** An output that applies when every condition in \`when\` holds. */
|
||||
export interface ToolIOCase {
|
||||
when: ToolIOWhen[];
|
||||
produces: ToolFormat;
|
||||
arity: ToolArity;
|
||||
}
|
||||
|
||||
/** What one endpoint accepts and produces. */
|
||||
export interface ToolIOSpec {
|
||||
accepts: ToolFormat[];
|
||||
produces: ToolFormat;
|
||||
arity: ToolArity;
|
||||
cases?: ToolIOCase[];
|
||||
}
|
||||
|
||||
/**
|
||||
* What each endpoint accepts and produces, keyed by {@link ToolEndpoint} so the paths live in
|
||||
* exactly one place. Partial: endpoints that manage a session, a device or a stored resource
|
||||
* rather than transform a document declare nothing.
|
||||
*/
|
||||
export type ToolIOTable = Partial<Record<ToolEndpoint, ToolIOSpec>>;
|
||||
|
||||
export const TOOL_IO: ToolIOTable = ${JSON.stringify(table)};
|
||||
|
||||
/** The declaration for an endpoint, or undefined when it declares none. */
|
||||
export function toolIOFor(
|
||||
operation: string,
|
||||
table: ToolIOTable = TOOL_IO,
|
||||
): ToolIOSpec | undefined {
|
||||
// The table's own keys are the valid set, so a hasOwn guard is the narrowing.
|
||||
return Object.hasOwn(table, operation)
|
||||
? table[operation as ToolEndpoint]
|
||||
: undefined;
|
||||
}
|
||||
`;
|
||||
|
||||
const prettierConfig = await prettier.resolveConfig(outputPath);
|
||||
return prettier.format(body, { ...prettierConfig, parser: "typescript" });
|
||||
}
|
||||
|
||||
/** In check mode, fail when the committed file is out of date. */
|
||||
function writeOrCheck(
|
||||
outputPath: string,
|
||||
formatted: string,
|
||||
check: boolean,
|
||||
task: string,
|
||||
): void {
|
||||
if (check) {
|
||||
let current = "";
|
||||
try {
|
||||
current = readFileSync(outputPath, "utf-8");
|
||||
} catch {
|
||||
// Missing file counts as out of date.
|
||||
}
|
||||
if (current !== formatted) {
|
||||
throw new Error(
|
||||
`${outputPath} is out of date. Run '${task}' and commit the result.`,
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
mkdirSync(dirname(outputPath), { recursive: true });
|
||||
writeFileSync(outputPath, formatted, "utf-8");
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const { values } = parseArgs({
|
||||
options: {
|
||||
spec: { type: "string" },
|
||||
output: { type: "string" },
|
||||
"io-output": { type: "string" },
|
||||
check: { type: "boolean", default: false },
|
||||
},
|
||||
});
|
||||
if (!values.spec || !values.output) {
|
||||
if (!values.spec || !values.output || !values["io-output"]) {
|
||||
throw new Error(
|
||||
"Usage: generate-tool-api-types.mts --spec <SwaggerDoc.json> --output <file.ts> [--check]",
|
||||
"Usage: generate-tool-api-types.mts --spec <SwaggerDoc.json> --output <file.ts> --io-output <file.ts> [--check]",
|
||||
);
|
||||
}
|
||||
const specPath = resolve(values.spec);
|
||||
const outputPath = resolve(values.output);
|
||||
const ioOutputPath = resolve(values["io-output"]);
|
||||
|
||||
const spec = JSON.parse(readFileSync(specPath, "utf-8")) as Json;
|
||||
|
||||
const paths = isObject(spec.paths) ? spec.paths : {};
|
||||
const components =
|
||||
isObject(spec.components) && isObject(spec.components.schemas)
|
||||
@@ -267,6 +425,25 @@ async function main(): Promise<void> {
|
||||
tools.push({ path, className });
|
||||
}
|
||||
|
||||
const { table: ioDeclarations, dropped } = collectToolIO(
|
||||
paths,
|
||||
new Set(tools.map((tool) => tool.path)),
|
||||
);
|
||||
if (dropped.length > 0) {
|
||||
console.warn(
|
||||
`Dropped ${dropped.length} @ToolIO declaration(s) on paths that are not tool endpoints. Add the namespace to ALLOWED_PATH_PREFIXES if a pipeline can contain these steps:\n ${dropped.join("\n ")}`,
|
||||
);
|
||||
}
|
||||
writeOrCheck(
|
||||
ioOutputPath,
|
||||
await renderToolIO(spec, ioDeclarations, ioOutputPath),
|
||||
values.check ?? false,
|
||||
"task frontend:tool-models",
|
||||
);
|
||||
console.log(
|
||||
`${values.check ? "Up to date" : "Generated"}: ${Object.keys(ioDeclarations).length} tool I/O declarations.`,
|
||||
);
|
||||
|
||||
// Transitively inline every referenced component into `definitions`, rewriting its refs too.
|
||||
const queue = [...pendingComponents];
|
||||
while (queue.length > 0) {
|
||||
@@ -368,26 +545,11 @@ async function compileAndWrite(
|
||||
parser: "typescript",
|
||||
});
|
||||
|
||||
if (check) {
|
||||
let current = "";
|
||||
try {
|
||||
current = readFileSync(outputPath, "utf-8");
|
||||
} catch {
|
||||
// Missing file counts as out of date.
|
||||
}
|
||||
if (current !== formatted) {
|
||||
throw new Error(
|
||||
`${outputPath} is out of date. Run 'task frontend:tool-models' and commit the result.`,
|
||||
);
|
||||
}
|
||||
console.log(`Up to date: ${tools.length} tool endpoints.`);
|
||||
return;
|
||||
}
|
||||
|
||||
mkdirSync(dirname(outputPath), { recursive: true });
|
||||
writeFileSync(outputPath, formatted, "utf-8");
|
||||
console.log(`Generated ${tools.length} tool endpoints -> ${outputPath}`);
|
||||
if (skipped.length > 0) {
|
||||
writeOrCheck(outputPath, formatted, check, "task frontend:tool-models");
|
||||
console.log(
|
||||
`${check ? "Up to date" : "Generated"}: ${tools.length} tool endpoints.`,
|
||||
);
|
||||
if (!check && skipped.length > 0) {
|
||||
console.log(
|
||||
`Skipped ${skipped.length} POST endpoint(s) with no request body: ${skipped.join(", ")}`,
|
||||
);
|
||||
|
||||
@@ -276,6 +276,16 @@ export interface BookletImpositionRequest {
|
||||
*/
|
||||
spineLocation?: "LEFT" | "RIGHT";
|
||||
}
|
||||
export interface ContainsTextRequest {
|
||||
/**
|
||||
* The pages to select, Supports ranges (e.g., '1,3,5-9'), or 'all' or functions in the format 'an+b' where 'a' is the multiplier of the page number 'n', and 'b' is a constant (e.g., '2n+1', '3n', '6n-5')
|
||||
*/
|
||||
pageNumbers?: string;
|
||||
/**
|
||||
* The text to check for
|
||||
*/
|
||||
text?: string;
|
||||
}
|
||||
export interface ConvertCbrToPdfRequest {
|
||||
/**
|
||||
* Optimize the output PDF for ebook reading using Ghostscript
|
||||
@@ -495,6 +505,16 @@ export interface ExtractImageScansRequest {
|
||||
*/
|
||||
tolerance?: number;
|
||||
}
|
||||
export interface FileSizeRequest {
|
||||
/**
|
||||
* The comparison type, accepts Greater, Equal, Less than
|
||||
*/
|
||||
comparator: "Greater" | "Equal" | "Less";
|
||||
/**
|
||||
* Size of the file in bytes
|
||||
*/
|
||||
fileSize?: number;
|
||||
}
|
||||
export interface FlattenRequest {
|
||||
/**
|
||||
* True to flatten only the forms, false to flatten full PDF (Convert page to image)
|
||||
@@ -517,6 +537,33 @@ export interface HTMLToPdfRequest {
|
||||
*/
|
||||
zoom?: number;
|
||||
}
|
||||
export interface IntegrationExternalApiCallRequest {
|
||||
bodyMode?: string;
|
||||
bodyTemplate?: string;
|
||||
connectionId: string;
|
||||
fields?: string;
|
||||
fileFieldName?: string;
|
||||
headers?: string;
|
||||
includeContext?: boolean;
|
||||
includeFile?: boolean;
|
||||
method?: string;
|
||||
path?: string;
|
||||
requireTrue?: string;
|
||||
responseMode?: string;
|
||||
responseSelect?: string;
|
||||
resultUrlHeader?: string;
|
||||
resultUrlPath?: string;
|
||||
}
|
||||
export interface IntegrationPurviewApplyLabelRequest {
|
||||
connectionId: string;
|
||||
contentBits?: number;
|
||||
labelId: string;
|
||||
labelName?: string;
|
||||
method?: string;
|
||||
}
|
||||
export interface IntegrationPurviewReadLabelRequest {
|
||||
connectionId: string;
|
||||
}
|
||||
export type ListAttachmentsRequest = Record<string, never>;
|
||||
export interface ManualRedactPdfRequest {
|
||||
/**
|
||||
@@ -772,6 +819,16 @@ export interface OverlayPdfsRequest {
|
||||
*/
|
||||
overlayPosition: 0 | 1;
|
||||
}
|
||||
export interface PDFComparisonAndCount {
|
||||
/**
|
||||
* The comparison type, accepts Greater, Equal, Less than
|
||||
*/
|
||||
comparator: "Greater" | "Equal" | "Less";
|
||||
/**
|
||||
* Count
|
||||
*/
|
||||
pageCount?: number;
|
||||
}
|
||||
export interface PDFExtractImagesRequest {
|
||||
/**
|
||||
* The output image format e.g., 'png', 'jpeg', or 'gif'
|
||||
@@ -791,6 +848,35 @@ export interface PDFWithPageNums {
|
||||
*/
|
||||
pageNumbers?: string;
|
||||
}
|
||||
export interface PageRotationRequest {
|
||||
/**
|
||||
* The comparison type, accepts Greater, Equal, Less than
|
||||
*/
|
||||
comparator: "Greater" | "Equal" | "Less";
|
||||
/**
|
||||
* Rotation in degrees
|
||||
*/
|
||||
rotation?: number;
|
||||
}
|
||||
export interface PageSizeRequest {
|
||||
/**
|
||||
* The comparison type, accepts Greater, Equal, Less than
|
||||
*/
|
||||
comparator: "Greater" | "Equal" | "Less";
|
||||
/**
|
||||
* Standard Page Size
|
||||
*/
|
||||
standardPageSize?:
|
||||
| "A0"
|
||||
| "A1"
|
||||
| "A2"
|
||||
| "A3"
|
||||
| "A4"
|
||||
| "A5"
|
||||
| "A6"
|
||||
| "LETTER"
|
||||
| "LEGAL";
|
||||
}
|
||||
export interface PdfToPdfARequest {
|
||||
/**
|
||||
* The output format type (PDF/A or PDF/X)
|
||||
@@ -1383,6 +1469,12 @@ export type ToolEndpoint =
|
||||
| "/api/v1/convert/text-editor/pdf"
|
||||
| "/api/v1/convert/url/pdf"
|
||||
| "/api/v1/convert/vector/pdf"
|
||||
| "/api/v1/filter/filter-contains-image"
|
||||
| "/api/v1/filter/filter-contains-text"
|
||||
| "/api/v1/filter/filter-file-size"
|
||||
| "/api/v1/filter/filter-page-count"
|
||||
| "/api/v1/filter/filter-page-rotation"
|
||||
| "/api/v1/filter/filter-page-size"
|
||||
| "/api/v1/general/booklet-imposition"
|
||||
| "/api/v1/general/crop"
|
||||
| "/api/v1/general/edit-table-of-contents"
|
||||
@@ -1402,6 +1494,9 @@ export type ToolEndpoint =
|
||||
| "/api/v1/general/split-pages"
|
||||
| "/api/v1/general/split-pdf-by-chapters"
|
||||
| "/api/v1/general/split-pdf-by-sections"
|
||||
| "/api/v1/integration/external-api-call"
|
||||
| "/api/v1/integration/purview-apply-label"
|
||||
| "/api/v1/integration/purview-read-label"
|
||||
| "/api/v1/misc/add-attachments"
|
||||
| "/api/v1/misc/add-comments"
|
||||
| "/api/v1/misc/add-image"
|
||||
@@ -1474,6 +1569,12 @@ export interface ToolApiParams {
|
||||
"/api/v1/convert/text-editor/pdf": GeneralFile;
|
||||
"/api/v1/convert/url/pdf": UrlToPdfRequest;
|
||||
"/api/v1/convert/vector/pdf": PdfVectorExportRequest;
|
||||
"/api/v1/filter/filter-contains-image": PDFWithPageNums;
|
||||
"/api/v1/filter/filter-contains-text": ContainsTextRequest;
|
||||
"/api/v1/filter/filter-file-size": FileSizeRequest;
|
||||
"/api/v1/filter/filter-page-count": PDFComparisonAndCount;
|
||||
"/api/v1/filter/filter-page-rotation": PageRotationRequest;
|
||||
"/api/v1/filter/filter-page-size": PageSizeRequest;
|
||||
"/api/v1/general/booklet-imposition": BookletImpositionRequest;
|
||||
"/api/v1/general/crop": CropPdfForm;
|
||||
"/api/v1/general/edit-table-of-contents": EditTableOfContentsRequest;
|
||||
@@ -1493,6 +1594,9 @@ export interface ToolApiParams {
|
||||
"/api/v1/general/split-pages": SplitPagesRequest;
|
||||
"/api/v1/general/split-pdf-by-chapters": SplitPdfByChaptersRequest;
|
||||
"/api/v1/general/split-pdf-by-sections": SplitPdfBySectionsRequest;
|
||||
"/api/v1/integration/external-api-call": IntegrationExternalApiCallRequest;
|
||||
"/api/v1/integration/purview-apply-label": IntegrationPurviewApplyLabelRequest;
|
||||
"/api/v1/integration/purview-read-label": IntegrationPurviewReadLabelRequest;
|
||||
"/api/v1/misc/add-attachments": AddAttachmentRequest;
|
||||
"/api/v1/misc/add-comments": AddCommentsRequest;
|
||||
"/api/v1/misc/add-image": OverlayImageRequest;
|
||||
@@ -1566,6 +1670,12 @@ export const TOOL_ENDPOINTS = [
|
||||
"/api/v1/convert/text-editor/pdf",
|
||||
"/api/v1/convert/url/pdf",
|
||||
"/api/v1/convert/vector/pdf",
|
||||
"/api/v1/filter/filter-contains-image",
|
||||
"/api/v1/filter/filter-contains-text",
|
||||
"/api/v1/filter/filter-file-size",
|
||||
"/api/v1/filter/filter-page-count",
|
||||
"/api/v1/filter/filter-page-rotation",
|
||||
"/api/v1/filter/filter-page-size",
|
||||
"/api/v1/general/booklet-imposition",
|
||||
"/api/v1/general/crop",
|
||||
"/api/v1/general/edit-table-of-contents",
|
||||
@@ -1585,6 +1695,9 @@ export const TOOL_ENDPOINTS = [
|
||||
"/api/v1/general/split-pages",
|
||||
"/api/v1/general/split-pdf-by-chapters",
|
||||
"/api/v1/general/split-pdf-by-sections",
|
||||
"/api/v1/integration/external-api-call",
|
||||
"/api/v1/integration/purview-apply-label",
|
||||
"/api/v1/integration/purview-read-label",
|
||||
"/api/v1/misc/add-attachments",
|
||||
"/api/v1/misc/add-comments",
|
||||
"/api/v1/misc/add-image",
|
||||
|
||||
@@ -0,0 +1,616 @@
|
||||
// AUTO-GENERATED FILE. DO NOT EDIT.
|
||||
// Generated by editor/scripts/generate-tool-api-types.mts from the Java OpenAPI spec
|
||||
// (SwaggerDoc.json). Regenerate with: task frontend:tool-models
|
||||
|
||||
/**
|
||||
* What each tool endpoint accepts and produces, so a chain can be checked while it is being
|
||||
* edited rather than by running it. Declared in Java with `@ToolIO`; see
|
||||
* `utils/toolIOCompat` for the compatibility rules that read this table.
|
||||
*/
|
||||
|
||||
import { type ToolEndpoint } from "@app/types/toolApiTypes";
|
||||
|
||||
/** The kind of file a tool consumes or produces. `ANY` accepts or produces anything; `NONE` means no file at all. */
|
||||
export type ToolFormat =
|
||||
| "PDF"
|
||||
| "PDF_ENCRYPTED"
|
||||
| "IMAGE"
|
||||
| "ZIP"
|
||||
| "WORD"
|
||||
| "PPT"
|
||||
| "EXCEL"
|
||||
| "CSV"
|
||||
| "HTML"
|
||||
| "XML"
|
||||
| "JSON"
|
||||
| "TEXT"
|
||||
| "MARKDOWN"
|
||||
| "JAVASCRIPT"
|
||||
| "EBOOK"
|
||||
| "EMAIL"
|
||||
| "POSTSCRIPT"
|
||||
| "PCL"
|
||||
| "XPS"
|
||||
| "VIDEO"
|
||||
| "CBZ"
|
||||
| "CBR"
|
||||
| "ANY"
|
||||
| "NONE";
|
||||
|
||||
/** Every format, for iteration. */
|
||||
export const TOOL_FORMATS = [
|
||||
"PDF",
|
||||
"PDF_ENCRYPTED",
|
||||
"IMAGE",
|
||||
"ZIP",
|
||||
"WORD",
|
||||
"PPT",
|
||||
"EXCEL",
|
||||
"CSV",
|
||||
"HTML",
|
||||
"XML",
|
||||
"JSON",
|
||||
"TEXT",
|
||||
"MARKDOWN",
|
||||
"JAVASCRIPT",
|
||||
"EBOOK",
|
||||
"EMAIL",
|
||||
"POSTSCRIPT",
|
||||
"PCL",
|
||||
"XPS",
|
||||
"VIDEO",
|
||||
"CBZ",
|
||||
"CBR",
|
||||
"ANY",
|
||||
"NONE",
|
||||
] as const satisfies readonly ToolFormat[];
|
||||
|
||||
/** How many files go in and come out. A multi-output tool returns its results zipped, and the caller unpacks them. */
|
||||
export type ToolArity = "SISO" | "SIMO" | "MISO" | "MIMO";
|
||||
|
||||
/** One condition on a request parameter, guarding a {@link ToolIOCase}. */
|
||||
export interface ToolIOWhen {
|
||||
param: string;
|
||||
matches: string[];
|
||||
}
|
||||
|
||||
/** An output that applies when every condition in `when` holds. */
|
||||
export interface ToolIOCase {
|
||||
when: ToolIOWhen[];
|
||||
produces: ToolFormat;
|
||||
arity: ToolArity;
|
||||
}
|
||||
|
||||
/** What one endpoint accepts and produces. */
|
||||
export interface ToolIOSpec {
|
||||
accepts: ToolFormat[];
|
||||
produces: ToolFormat;
|
||||
arity: ToolArity;
|
||||
cases?: ToolIOCase[];
|
||||
}
|
||||
|
||||
/**
|
||||
* What each endpoint accepts and produces, keyed by {@link ToolEndpoint} so the paths live in
|
||||
* exactly one place. Partial: endpoints that manage a session, a device or a stored resource
|
||||
* rather than transform a document declare nothing.
|
||||
*/
|
||||
export type ToolIOTable = Partial<Record<ToolEndpoint, ToolIOSpec>>;
|
||||
|
||||
export const TOOL_IO: ToolIOTable = {
|
||||
"/api/v1/convert/cbr/pdf": {
|
||||
accepts: ["CBR"],
|
||||
produces: "PDF",
|
||||
arity: "SISO",
|
||||
},
|
||||
"/api/v1/convert/cbz/pdf": {
|
||||
accepts: ["CBZ"],
|
||||
produces: "PDF",
|
||||
arity: "SISO",
|
||||
},
|
||||
"/api/v1/convert/ebook/pdf": {
|
||||
accepts: ["EBOOK"],
|
||||
produces: "PDF",
|
||||
arity: "SISO",
|
||||
},
|
||||
"/api/v1/convert/eml/pdf": {
|
||||
accepts: ["EMAIL"],
|
||||
produces: "PDF",
|
||||
arity: "SISO",
|
||||
},
|
||||
"/api/v1/convert/file/pdf": {
|
||||
accepts: ["ANY"],
|
||||
produces: "PDF",
|
||||
arity: "SISO",
|
||||
},
|
||||
"/api/v1/convert/html/pdf": {
|
||||
accepts: ["HTML", "ZIP"],
|
||||
produces: "PDF",
|
||||
arity: "SISO",
|
||||
},
|
||||
"/api/v1/convert/img/pdf": {
|
||||
accepts: ["IMAGE"],
|
||||
produces: "PDF",
|
||||
arity: "MISO",
|
||||
},
|
||||
"/api/v1/convert/markdown/pdf": {
|
||||
accepts: ["MARKDOWN", "ZIP"],
|
||||
produces: "PDF",
|
||||
arity: "SISO",
|
||||
},
|
||||
"/api/v1/convert/pdf/cbr": {
|
||||
accepts: ["PDF"],
|
||||
produces: "CBR",
|
||||
arity: "SISO",
|
||||
},
|
||||
"/api/v1/convert/pdf/cbz": {
|
||||
accepts: ["PDF"],
|
||||
produces: "CBZ",
|
||||
arity: "SISO",
|
||||
},
|
||||
"/api/v1/convert/pdf/csv": {
|
||||
accepts: ["PDF"],
|
||||
produces: "CSV",
|
||||
arity: "SIMO",
|
||||
},
|
||||
"/api/v1/convert/pdf/epub": {
|
||||
accepts: ["PDF"],
|
||||
produces: "EBOOK",
|
||||
arity: "SISO",
|
||||
},
|
||||
"/api/v1/convert/pdf/html": {
|
||||
accepts: ["PDF"],
|
||||
produces: "ZIP",
|
||||
arity: "SISO",
|
||||
},
|
||||
"/api/v1/convert/pdf/img": {
|
||||
accepts: ["PDF"],
|
||||
produces: "IMAGE",
|
||||
arity: "SIMO",
|
||||
cases: [
|
||||
{
|
||||
when: [{ param: "singleOrMultiple", matches: ["single"] }],
|
||||
produces: "IMAGE",
|
||||
arity: "SISO",
|
||||
},
|
||||
],
|
||||
},
|
||||
"/api/v1/convert/pdf/markdown": {
|
||||
accepts: ["PDF"],
|
||||
produces: "MARKDOWN",
|
||||
arity: "SISO",
|
||||
},
|
||||
"/api/v1/convert/pdf/pdfa": {
|
||||
accepts: ["PDF"],
|
||||
produces: "PDF",
|
||||
arity: "SISO",
|
||||
},
|
||||
"/api/v1/convert/pdf/presentation": {
|
||||
accepts: ["PDF"],
|
||||
produces: "PPT",
|
||||
arity: "SISO",
|
||||
},
|
||||
"/api/v1/convert/pdf/text": {
|
||||
accepts: ["PDF"],
|
||||
produces: "TEXT",
|
||||
arity: "SISO",
|
||||
cases: [
|
||||
{
|
||||
when: [{ param: "outputFormat", matches: ["rtf"] }],
|
||||
produces: "WORD",
|
||||
arity: "SISO",
|
||||
},
|
||||
],
|
||||
},
|
||||
"/api/v1/convert/pdf/vector": {
|
||||
accepts: ["PDF"],
|
||||
produces: "IMAGE",
|
||||
arity: "SISO",
|
||||
cases: [
|
||||
{
|
||||
when: [{ param: "outputFormat", matches: ["ps"] }],
|
||||
produces: "POSTSCRIPT",
|
||||
arity: "SISO",
|
||||
},
|
||||
{
|
||||
when: [{ param: "outputFormat", matches: ["pcl"] }],
|
||||
produces: "PCL",
|
||||
arity: "SISO",
|
||||
},
|
||||
{
|
||||
when: [{ param: "outputFormat", matches: ["xps"] }],
|
||||
produces: "XPS",
|
||||
arity: "SISO",
|
||||
},
|
||||
],
|
||||
},
|
||||
"/api/v1/convert/pdf/word": {
|
||||
accepts: ["PDF"],
|
||||
produces: "WORD",
|
||||
arity: "SISO",
|
||||
},
|
||||
"/api/v1/convert/pdf/xlsx": {
|
||||
accepts: ["PDF"],
|
||||
produces: "EXCEL",
|
||||
arity: "SISO",
|
||||
},
|
||||
"/api/v1/convert/pdf/xml": {
|
||||
accepts: ["PDF"],
|
||||
produces: "XML",
|
||||
arity: "SISO",
|
||||
},
|
||||
"/api/v1/convert/svg/pdf": {
|
||||
accepts: ["IMAGE"],
|
||||
produces: "PDF",
|
||||
arity: "MIMO",
|
||||
cases: [
|
||||
{
|
||||
when: [{ param: "combineIntoSinglePdf", matches: ["true"] }],
|
||||
produces: "PDF",
|
||||
arity: "MISO",
|
||||
},
|
||||
],
|
||||
},
|
||||
"/api/v1/convert/url/pdf": {
|
||||
accepts: ["NONE"],
|
||||
produces: "PDF",
|
||||
arity: "SISO",
|
||||
},
|
||||
"/api/v1/convert/vector/pdf": {
|
||||
accepts: ["POSTSCRIPT"],
|
||||
produces: "PDF",
|
||||
arity: "SISO",
|
||||
},
|
||||
"/api/v1/filter/filter-contains-image": {
|
||||
accepts: ["PDF"],
|
||||
produces: "PDF",
|
||||
arity: "SISO",
|
||||
},
|
||||
"/api/v1/filter/filter-contains-text": {
|
||||
accepts: ["PDF"],
|
||||
produces: "PDF",
|
||||
arity: "SISO",
|
||||
},
|
||||
"/api/v1/filter/filter-file-size": {
|
||||
accepts: ["PDF"],
|
||||
produces: "PDF",
|
||||
arity: "SISO",
|
||||
},
|
||||
"/api/v1/filter/filter-page-count": {
|
||||
accepts: ["PDF"],
|
||||
produces: "PDF",
|
||||
arity: "SISO",
|
||||
},
|
||||
"/api/v1/filter/filter-page-rotation": {
|
||||
accepts: ["PDF"],
|
||||
produces: "PDF",
|
||||
arity: "SISO",
|
||||
},
|
||||
"/api/v1/filter/filter-page-size": {
|
||||
accepts: ["PDF"],
|
||||
produces: "PDF",
|
||||
arity: "SISO",
|
||||
},
|
||||
"/api/v1/general/booklet-imposition": {
|
||||
accepts: ["PDF"],
|
||||
produces: "PDF",
|
||||
arity: "SISO",
|
||||
},
|
||||
"/api/v1/general/crop": { accepts: ["PDF"], produces: "PDF", arity: "SISO" },
|
||||
"/api/v1/general/edit-table-of-contents": {
|
||||
accepts: ["PDF"],
|
||||
produces: "PDF",
|
||||
arity: "SISO",
|
||||
},
|
||||
"/api/v1/general/edit-text": {
|
||||
accepts: ["PDF"],
|
||||
produces: "PDF",
|
||||
arity: "SISO",
|
||||
},
|
||||
"/api/v1/general/extract-bookmarks": {
|
||||
accepts: ["PDF"],
|
||||
produces: "JSON",
|
||||
arity: "SISO",
|
||||
},
|
||||
"/api/v1/general/merge-pdfs": {
|
||||
accepts: ["PDF"],
|
||||
produces: "PDF",
|
||||
arity: "MISO",
|
||||
},
|
||||
"/api/v1/general/multi-page-layout": {
|
||||
accepts: ["PDF"],
|
||||
produces: "PDF",
|
||||
arity: "SISO",
|
||||
},
|
||||
"/api/v1/general/overlay-pdfs": {
|
||||
accepts: ["PDF"],
|
||||
produces: "PDF",
|
||||
arity: "MISO",
|
||||
},
|
||||
"/api/v1/general/pdf-to-single-page": {
|
||||
accepts: ["PDF"],
|
||||
produces: "PDF",
|
||||
arity: "SISO",
|
||||
},
|
||||
"/api/v1/general/rearrange-pages": {
|
||||
accepts: ["PDF"],
|
||||
produces: "PDF",
|
||||
arity: "SISO",
|
||||
},
|
||||
"/api/v1/general/remove-image-pdf": {
|
||||
accepts: ["PDF"],
|
||||
produces: "PDF",
|
||||
arity: "SISO",
|
||||
},
|
||||
"/api/v1/general/remove-pages": {
|
||||
accepts: ["PDF"],
|
||||
produces: "PDF",
|
||||
arity: "SISO",
|
||||
},
|
||||
"/api/v1/general/rotate-pdf": {
|
||||
accepts: ["PDF"],
|
||||
produces: "PDF",
|
||||
arity: "SISO",
|
||||
},
|
||||
"/api/v1/general/scale-pages": {
|
||||
accepts: ["PDF"],
|
||||
produces: "PDF",
|
||||
arity: "SISO",
|
||||
},
|
||||
"/api/v1/general/split-by-size-or-count": {
|
||||
accepts: ["PDF"],
|
||||
produces: "PDF",
|
||||
arity: "SIMO",
|
||||
},
|
||||
"/api/v1/general/split-for-poster-print": {
|
||||
accepts: ["PDF"],
|
||||
produces: "PDF",
|
||||
arity: "SIMO",
|
||||
},
|
||||
"/api/v1/general/split-pages": {
|
||||
accepts: ["PDF"],
|
||||
produces: "PDF",
|
||||
arity: "SIMO",
|
||||
},
|
||||
"/api/v1/general/split-pdf-by-chapters": {
|
||||
accepts: ["PDF"],
|
||||
produces: "PDF",
|
||||
arity: "SIMO",
|
||||
},
|
||||
"/api/v1/general/split-pdf-by-sections": {
|
||||
accepts: ["PDF"],
|
||||
produces: "PDF",
|
||||
arity: "SIMO",
|
||||
},
|
||||
"/api/v1/integration/external-api-call": {
|
||||
accepts: ["ANY"],
|
||||
produces: "ANY",
|
||||
arity: "SISO",
|
||||
},
|
||||
"/api/v1/integration/purview-apply-label": {
|
||||
accepts: ["PDF"],
|
||||
produces: "PDF",
|
||||
arity: "SISO",
|
||||
},
|
||||
"/api/v1/integration/purview-read-label": {
|
||||
accepts: ["PDF"],
|
||||
produces: "PDF",
|
||||
arity: "SISO",
|
||||
},
|
||||
"/api/v1/misc/add-attachments": {
|
||||
accepts: ["PDF"],
|
||||
produces: "PDF",
|
||||
arity: "SISO",
|
||||
},
|
||||
"/api/v1/misc/add-comments": {
|
||||
accepts: ["PDF"],
|
||||
produces: "PDF",
|
||||
arity: "SISO",
|
||||
},
|
||||
"/api/v1/misc/add-image": {
|
||||
accepts: ["PDF"],
|
||||
produces: "PDF",
|
||||
arity: "SISO",
|
||||
},
|
||||
"/api/v1/misc/add-page-numbers": {
|
||||
accepts: ["PDF"],
|
||||
produces: "PDF",
|
||||
arity: "SISO",
|
||||
},
|
||||
"/api/v1/misc/add-stamp": {
|
||||
accepts: ["PDF"],
|
||||
produces: "PDF",
|
||||
arity: "SISO",
|
||||
},
|
||||
"/api/v1/misc/auto-rename": {
|
||||
accepts: ["PDF"],
|
||||
produces: "PDF",
|
||||
arity: "SISO",
|
||||
},
|
||||
"/api/v1/misc/auto-rotate-pdf": {
|
||||
accepts: ["PDF"],
|
||||
produces: "PDF",
|
||||
arity: "SISO",
|
||||
cases: [
|
||||
{
|
||||
when: [{ param: "dryRun", matches: ["true"] }],
|
||||
produces: "JSON",
|
||||
arity: "SISO",
|
||||
},
|
||||
],
|
||||
},
|
||||
"/api/v1/misc/auto-split-pdf": {
|
||||
accepts: ["PDF"],
|
||||
produces: "PDF",
|
||||
arity: "SIMO",
|
||||
},
|
||||
"/api/v1/misc/compress-pdf": {
|
||||
accepts: ["PDF"],
|
||||
produces: "PDF",
|
||||
arity: "SISO",
|
||||
},
|
||||
"/api/v1/misc/decompress-pdf": {
|
||||
accepts: ["PDF"],
|
||||
produces: "PDF",
|
||||
arity: "SISO",
|
||||
},
|
||||
"/api/v1/misc/delete-attachment": {
|
||||
accepts: ["PDF"],
|
||||
produces: "PDF",
|
||||
arity: "SISO",
|
||||
},
|
||||
"/api/v1/misc/extract-attachments": {
|
||||
accepts: ["PDF"],
|
||||
produces: "ZIP",
|
||||
arity: "SISO",
|
||||
},
|
||||
"/api/v1/misc/extract-image-scans": {
|
||||
accepts: ["PDF"],
|
||||
produces: "IMAGE",
|
||||
arity: "SIMO",
|
||||
},
|
||||
"/api/v1/misc/extract-images": {
|
||||
accepts: ["PDF"],
|
||||
produces: "IMAGE",
|
||||
arity: "SIMO",
|
||||
},
|
||||
"/api/v1/misc/flatten": { accepts: ["PDF"], produces: "PDF", arity: "SISO" },
|
||||
"/api/v1/misc/list-attachments": {
|
||||
accepts: ["PDF"],
|
||||
produces: "JSON",
|
||||
arity: "SISO",
|
||||
},
|
||||
"/api/v1/misc/ocr-pdf": {
|
||||
accepts: ["PDF"],
|
||||
produces: "PDF",
|
||||
arity: "SISO",
|
||||
cases: [
|
||||
{
|
||||
when: [{ param: "sidecar", matches: ["true"] }],
|
||||
produces: "ZIP",
|
||||
arity: "SISO",
|
||||
},
|
||||
],
|
||||
},
|
||||
"/api/v1/misc/remove-blanks": {
|
||||
accepts: ["PDF"],
|
||||
produces: "PDF",
|
||||
arity: "SIMO",
|
||||
},
|
||||
"/api/v1/misc/rename-attachment": {
|
||||
accepts: ["PDF"],
|
||||
produces: "PDF",
|
||||
arity: "SISO",
|
||||
},
|
||||
"/api/v1/misc/repair": { accepts: ["PDF"], produces: "PDF", arity: "SISO" },
|
||||
"/api/v1/misc/replace-invert-pdf": {
|
||||
accepts: ["PDF"],
|
||||
produces: "PDF",
|
||||
arity: "SISO",
|
||||
},
|
||||
"/api/v1/misc/scanner-effect": {
|
||||
accepts: ["PDF"],
|
||||
produces: "PDF",
|
||||
arity: "SISO",
|
||||
},
|
||||
"/api/v1/misc/show-javascript": {
|
||||
accepts: ["PDF"],
|
||||
produces: "JAVASCRIPT",
|
||||
arity: "SISO",
|
||||
},
|
||||
"/api/v1/misc/unlock-pdf-forms": {
|
||||
accepts: ["PDF"],
|
||||
produces: "PDF",
|
||||
arity: "SISO",
|
||||
},
|
||||
"/api/v1/misc/update-metadata": {
|
||||
accepts: ["PDF"],
|
||||
produces: "PDF",
|
||||
arity: "SISO",
|
||||
},
|
||||
"/api/v1/security/add-password": {
|
||||
accepts: ["PDF"],
|
||||
produces: "PDF_ENCRYPTED",
|
||||
arity: "SISO",
|
||||
cases: [
|
||||
{
|
||||
when: [
|
||||
{ param: "password", matches: [""] },
|
||||
{ param: "ownerPassword", matches: [""] },
|
||||
],
|
||||
produces: "PDF",
|
||||
arity: "SISO",
|
||||
},
|
||||
],
|
||||
},
|
||||
"/api/v1/security/add-watermark": {
|
||||
accepts: ["PDF"],
|
||||
produces: "PDF",
|
||||
arity: "SISO",
|
||||
},
|
||||
"/api/v1/security/auto-redact": {
|
||||
accepts: ["PDF"],
|
||||
produces: "PDF",
|
||||
arity: "SISO",
|
||||
},
|
||||
"/api/v1/security/cert-sign": {
|
||||
accepts: ["PDF"],
|
||||
produces: "PDF",
|
||||
arity: "SISO",
|
||||
},
|
||||
"/api/v1/security/get-info-on-pdf": {
|
||||
accepts: ["PDF"],
|
||||
produces: "JSON",
|
||||
arity: "SISO",
|
||||
},
|
||||
"/api/v1/security/redact": {
|
||||
accepts: ["PDF"],
|
||||
produces: "PDF",
|
||||
arity: "SISO",
|
||||
},
|
||||
"/api/v1/security/redact-execute": {
|
||||
accepts: ["PDF"],
|
||||
produces: "PDF",
|
||||
arity: "SISO",
|
||||
},
|
||||
"/api/v1/security/remove-cert-sign": {
|
||||
accepts: ["PDF"],
|
||||
produces: "PDF",
|
||||
arity: "SISO",
|
||||
},
|
||||
"/api/v1/security/remove-password": {
|
||||
accepts: ["PDF", "PDF_ENCRYPTED"],
|
||||
produces: "PDF",
|
||||
arity: "SISO",
|
||||
},
|
||||
"/api/v1/security/sanitize-pdf": {
|
||||
accepts: ["PDF"],
|
||||
produces: "PDF",
|
||||
arity: "SISO",
|
||||
},
|
||||
"/api/v1/security/timestamp-pdf": {
|
||||
accepts: ["PDF"],
|
||||
produces: "PDF",
|
||||
arity: "SISO",
|
||||
},
|
||||
"/api/v1/security/validate-signature": {
|
||||
accepts: ["PDF"],
|
||||
produces: "JSON",
|
||||
arity: "SISO",
|
||||
},
|
||||
"/api/v1/security/verify-pdf": {
|
||||
accepts: ["PDF"],
|
||||
produces: "JSON",
|
||||
arity: "SISO",
|
||||
},
|
||||
};
|
||||
|
||||
/** The declaration for an endpoint, or undefined when it declares none. */
|
||||
export function toolIOFor(
|
||||
operation: string,
|
||||
table: ToolIOTable = TOOL_IO,
|
||||
): ToolIOSpec | undefined {
|
||||
// The table's own keys are the valid set, so a hasOwn guard is the narrowing.
|
||||
return Object.hasOwn(table, operation)
|
||||
? table[operation as ToolEndpoint]
|
||||
: undefined;
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
/** The shared cases in `testing/tool-io-cases.json`, which all three implementations run. */
|
||||
|
||||
import { readFileSync } from "node:fs";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
validateToolChain,
|
||||
type ToolChainStep,
|
||||
type ToolDiagnostic,
|
||||
} from "@app/utils/toolIOCompat";
|
||||
import { type ToolEndpoint, TOOL_ENDPOINTS } from "@app/types/toolApiTypes";
|
||||
import {
|
||||
type ToolFormat,
|
||||
type ToolIOSpec,
|
||||
type ToolIOTable,
|
||||
} from "@app/types/toolIO";
|
||||
|
||||
interface SharedCase {
|
||||
name: string;
|
||||
sourceFormat?: ToolFormat;
|
||||
steps: { spec: string | null; parameters?: Record<string, unknown> }[];
|
||||
expected: { stepIndex: number; severity: string; code: string }[];
|
||||
}
|
||||
|
||||
/** Shared with the backend and engine, so it lives at the repo root. */
|
||||
function casesFile(): string {
|
||||
let current = dirname(new URL(import.meta.url).pathname);
|
||||
for (let i = 0; i < 12; i++) {
|
||||
const candidate = resolve(current, "testing/tool-io-cases.json");
|
||||
try {
|
||||
readFileSync(candidate);
|
||||
return candidate;
|
||||
} catch {
|
||||
current = resolve(current, "..");
|
||||
}
|
||||
}
|
||||
throw new Error(
|
||||
"testing/tool-io-cases.json not found above the test directory",
|
||||
);
|
||||
}
|
||||
|
||||
const data = JSON.parse(readFileSync(casesFile(), "utf-8")) as {
|
||||
specs: Record<string, ToolIOSpec>;
|
||||
cases: SharedCase[];
|
||||
};
|
||||
|
||||
/** The detail payload is free, so compare only the contractual parts. */
|
||||
function summarise(diagnostics: ToolDiagnostic[]): string[] {
|
||||
return diagnostics.map((d) => `${d.stepIndex}:${d.severity}:${d.code}`);
|
||||
}
|
||||
|
||||
describe("tool chain conformance", () => {
|
||||
for (const testCase of data.cases) {
|
||||
it(testCase.name, () => {
|
||||
const table: ToolIOTable = {};
|
||||
const steps: ToolChainStep[] = testCase.steps.map((step, index) => {
|
||||
// Distinct endpoints so the same spec can appear twice in a chain. Which ones is
|
||||
// irrelevant: the case supplies its own table, these are just keys.
|
||||
const operation: ToolEndpoint = TOOL_ENDPOINTS[index];
|
||||
if (step.spec !== null) table[operation] = data.specs[step.spec];
|
||||
return { operation, parameters: step.parameters };
|
||||
});
|
||||
|
||||
const actual = validateToolChain(steps, {
|
||||
sourceFormat: testCase.sourceFormat,
|
||||
toolIO: table,
|
||||
});
|
||||
|
||||
expect(summarise(actual)).toEqual(
|
||||
testCase.expected.map((e) => `${e.stepIndex}:${e.severity}:${e.code}`),
|
||||
);
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,265 @@
|
||||
/**
|
||||
* Whether a chain of steps can run, from the generated I/O table rather than by running it.
|
||||
*
|
||||
* Duplicated in the backend (`ToolChainValidator`) and the AI engine (`tool_io_compat.py`) on
|
||||
* purpose: this runs on every keystroke, and the desktop build has no cloud backend to ask.
|
||||
* `testing/tool-io-cases.json` pins all three to the same answers.
|
||||
*/
|
||||
|
||||
import {
|
||||
TOOL_IO,
|
||||
toolIOFor,
|
||||
type ToolArity,
|
||||
type ToolFormat,
|
||||
type ToolIOSpec,
|
||||
type ToolIOTable,
|
||||
} from "@app/types/toolIO";
|
||||
|
||||
export type ToolDiagnosticSeverity = "ERROR" | "WARN" | "INFO";
|
||||
|
||||
/** Stable identifiers so the UI can pick its own wording. */
|
||||
export const ToolDiagnosticCode = {
|
||||
/** The step declares no I/O, so nothing past it can be checked. */
|
||||
Undeclared: "undeclared-operation",
|
||||
/** The previous step's output is not a format this step accepts. */
|
||||
FormatMismatch: "format-mismatch",
|
||||
/** The previous step's output depends on a parameter that is not set yet. */
|
||||
OutputUncertain: "output-uncertain",
|
||||
/** The pipeline's input files are not a format the first step accepts. */
|
||||
SourceMismatch: "source-mismatch",
|
||||
/** The previous step emits several files and this one runs once per file. */
|
||||
FanOut: "fan-out",
|
||||
/** The previous step emits several files and this one consumes them together. */
|
||||
FanIn: "fan-in",
|
||||
} as const;
|
||||
|
||||
export type ToolDiagnosticCode =
|
||||
(typeof ToolDiagnosticCode)[keyof typeof ToolDiagnosticCode];
|
||||
|
||||
export interface ToolDiagnostic {
|
||||
stepIndex: number;
|
||||
severity: ToolDiagnosticSeverity;
|
||||
code: ToolDiagnosticCode;
|
||||
/** Interpolation values; the wording lives in the locale files. */
|
||||
detail: {
|
||||
operation: string;
|
||||
accepts?: ToolFormat[];
|
||||
produced?: ToolFormat;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ToolChainStep {
|
||||
/** A plain string: a stored pipeline may name an endpoint this build does not model. */
|
||||
operation: string;
|
||||
/** Only used to resolve an output that depends on one. */
|
||||
parameters?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface ToolChainOptions {
|
||||
/** The format entering step one, when known. */
|
||||
sourceFormat?: ToolFormat;
|
||||
toolIO?: ToolIOTable;
|
||||
}
|
||||
|
||||
interface ResolvedOutput {
|
||||
format: ToolFormat;
|
||||
arity: ToolArity;
|
||||
/** False when a conditional output turns on a parameter we cannot see. */
|
||||
certain: boolean;
|
||||
}
|
||||
|
||||
function isMultiInput(arity: ToolArity): boolean {
|
||||
return arity === "MISO" || arity === "MIMO";
|
||||
}
|
||||
|
||||
function isMultiOutput(arity: ToolArity): boolean {
|
||||
return arity === "SIMO" || arity === "MIMO";
|
||||
}
|
||||
|
||||
function normalise(value: unknown): string {
|
||||
return value === null || value === undefined
|
||||
? ""
|
||||
: String(value).trim().toLowerCase();
|
||||
}
|
||||
|
||||
function acceptsFormat(spec: ToolIOSpec, format: ToolFormat): boolean {
|
||||
return (
|
||||
format === "ANY" ||
|
||||
spec.accepts.includes("ANY") ||
|
||||
spec.accepts.includes(format)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* First case whose conditions all hold wins. If none match but one reads a parameter we cannot
|
||||
* see, the declared output comes back uncertain: an unseen value might have picked another branch.
|
||||
*/
|
||||
export function resolveOutput(
|
||||
spec: ToolIOSpec,
|
||||
parameters?: Record<string, unknown>,
|
||||
): ResolvedOutput {
|
||||
let sawUnknownParam = false;
|
||||
for (const rule of spec.cases ?? []) {
|
||||
let allHold = true;
|
||||
for (const condition of rule.when) {
|
||||
if (!parameters || !(condition.param in parameters)) {
|
||||
sawUnknownParam = true;
|
||||
allHold = false;
|
||||
continue;
|
||||
}
|
||||
const value = normalise(parameters[condition.param]);
|
||||
allHold &&= condition.matches.some((match) => normalise(match) === value);
|
||||
}
|
||||
if (allHold) {
|
||||
return { format: rule.produces, arity: rule.arity, certain: true };
|
||||
}
|
||||
}
|
||||
return {
|
||||
format: spec.produces,
|
||||
arity: spec.arity,
|
||||
certain: !sawUnknownParam,
|
||||
};
|
||||
}
|
||||
|
||||
export function validateToolChain(
|
||||
steps: ToolChainStep[],
|
||||
options: ToolChainOptions = {},
|
||||
): ToolDiagnostic[] {
|
||||
const table = options.toolIO ?? TOOL_IO;
|
||||
const diagnostics: ToolDiagnostic[] = [];
|
||||
let carried: ResolvedOutput | null = null;
|
||||
|
||||
steps.forEach((step, index) => {
|
||||
const spec = toolIOFor(step.operation, table);
|
||||
if (!spec) {
|
||||
diagnostics.push({
|
||||
stepIndex: index,
|
||||
severity: "WARN",
|
||||
code: ToolDiagnosticCode.Undeclared,
|
||||
detail: { operation: step.operation },
|
||||
});
|
||||
// Nothing is known past an undeclared step.
|
||||
carried = null;
|
||||
return;
|
||||
}
|
||||
|
||||
// Only the first step is handed the pipeline's input. Every later step is handed the previous
|
||||
// step's output, which is simply unknown once an undeclared step intervened - checking it
|
||||
// against the input again would judge it on a format it never receives.
|
||||
if (index === 0) {
|
||||
if (options.sourceFormat && !acceptsFormat(spec, options.sourceFormat)) {
|
||||
diagnostics.push({
|
||||
stepIndex: index,
|
||||
severity: "ERROR",
|
||||
code: ToolDiagnosticCode.SourceMismatch,
|
||||
detail: {
|
||||
operation: step.operation,
|
||||
accepts: spec.accepts,
|
||||
produced: options.sourceFormat,
|
||||
},
|
||||
});
|
||||
}
|
||||
} else if (carried !== null) {
|
||||
diagnostics.push(...checkTransition(index, step, spec, carried));
|
||||
}
|
||||
carried = resolveOutput(spec, step.parameters);
|
||||
});
|
||||
|
||||
return diagnostics;
|
||||
}
|
||||
|
||||
function checkTransition(
|
||||
index: number,
|
||||
step: ToolChainStep,
|
||||
spec: ToolIOSpec,
|
||||
previous: ResolvedOutput,
|
||||
): ToolDiagnostic[] {
|
||||
const detail = {
|
||||
operation: step.operation,
|
||||
accepts: spec.accepts,
|
||||
produced: previous.format,
|
||||
};
|
||||
|
||||
if (previous.format === "NONE") {
|
||||
return [
|
||||
{
|
||||
stepIndex: index,
|
||||
severity: "ERROR",
|
||||
code: ToolDiagnosticCode.FormatMismatch,
|
||||
detail,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
if (!acceptsFormat(spec, previous.format)) {
|
||||
return [
|
||||
{
|
||||
stepIndex: index,
|
||||
// Unresolved output: may yet be fine once the step is configured.
|
||||
severity: previous.certain ? "ERROR" : "WARN",
|
||||
code: previous.certain
|
||||
? ToolDiagnosticCode.FormatMismatch
|
||||
: ToolDiagnosticCode.OutputUncertain,
|
||||
detail,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
if (!previous.certain) {
|
||||
return [
|
||||
{
|
||||
stepIndex: index,
|
||||
severity: "WARN",
|
||||
code: ToolDiagnosticCode.OutputUncertain,
|
||||
detail,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
if (isMultiOutput(previous.arity)) {
|
||||
return [
|
||||
{
|
||||
stepIndex: index,
|
||||
severity: "INFO",
|
||||
code: isMultiInput(spec.arity)
|
||||
? ToolDiagnosticCode.FanIn
|
||||
: ToolDiagnosticCode.FanOut,
|
||||
detail,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
/** What a newly appended step would be handed, or undefined when the last step declares nothing. */
|
||||
export function chainOutputFormat(
|
||||
steps: ToolChainStep[],
|
||||
toolIO: ToolIOTable = TOOL_IO,
|
||||
): ToolFormat | undefined {
|
||||
const last = steps.at(-1);
|
||||
if (!last) return undefined;
|
||||
const spec = toolIOFor(last.operation, toolIO);
|
||||
return spec ? resolveOutput(spec, last.parameters).format : undefined;
|
||||
}
|
||||
|
||||
/** Unknown operations are not claimed to be a problem. */
|
||||
export function toolAcceptsFormat(
|
||||
operation: string,
|
||||
format: ToolFormat,
|
||||
toolIO: ToolIOTable = TOOL_IO,
|
||||
): boolean {
|
||||
const spec = toolIOFor(operation, toolIO);
|
||||
return spec ? acceptsFormat(spec, format) : true;
|
||||
}
|
||||
|
||||
export function hasBlockingDiagnostics(diagnostics: ToolDiagnostic[]): boolean {
|
||||
return diagnostics.some((d) => d.severity === "ERROR");
|
||||
}
|
||||
|
||||
export function diagnosticsForStep(
|
||||
diagnostics: ToolDiagnostic[],
|
||||
stepIndex: number,
|
||||
): ToolDiagnostic[] {
|
||||
return diagnostics.filter((d) => d.stepIndex === stepIndex);
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { TOOL_FORMATS, type ToolFormat } from "@app/types/toolIO";
|
||||
import {
|
||||
getToolFormatLabel,
|
||||
getToolFormatListLabel,
|
||||
} from "@app/utils/toolIOLabels";
|
||||
|
||||
/** The en-US `[toolFormat]` block, read straight from the locale file. */
|
||||
function toolFormatLabels(): Record<string, string> {
|
||||
let current = dirname(new URL(import.meta.url).pathname);
|
||||
for (let i = 0; i < 12; i++) {
|
||||
try {
|
||||
const toml = readFileSync(
|
||||
resolve(current, "public/locales/en-US/translation.toml"),
|
||||
"utf-8",
|
||||
);
|
||||
const section = toml.split("\n[toolFormat]\n")[1]?.split("\n[")[0] ?? "";
|
||||
return Object.fromEntries(
|
||||
section
|
||||
.split("\n")
|
||||
.map((line) => /^(\w+) = "(.*)"$/.exec(line.trim()))
|
||||
.filter((m): m is RegExpExecArray => m !== null)
|
||||
.map((m) => [m[1], m[2]]),
|
||||
);
|
||||
} catch {
|
||||
current = resolve(current, "..");
|
||||
}
|
||||
}
|
||||
throw new Error("en-US translation.toml not found above the test directory");
|
||||
}
|
||||
|
||||
const labels = toolFormatLabels();
|
||||
const t = ((key: string) =>
|
||||
labels[key.replace("toolFormat.", "")] ?? key) as never;
|
||||
|
||||
describe("tool format labels", () => {
|
||||
it("gives every format a phrase, so no wire value reaches the reader", () => {
|
||||
const missing = TOOL_FORMATS.filter((format) => !labels[format]);
|
||||
expect(missing).toEqual([]);
|
||||
});
|
||||
|
||||
it("never renders the enum name itself", () => {
|
||||
// PDF_ENCRYPTED is the giveaway: a label that still shouts in SCREAMING_SNAKE
|
||||
// is a wire value that escaped into the UI.
|
||||
for (const format of TOOL_FORMATS) {
|
||||
const label = getToolFormatLabel(t, format);
|
||||
expect(label).not.toMatch(/^[A-Z][A-Z_]*$/);
|
||||
}
|
||||
});
|
||||
|
||||
it("names an encrypted PDF in words", () => {
|
||||
expect(getToolFormatLabel(t, "PDF_ENCRYPTED")).toBe(
|
||||
"a password-protected PDF",
|
||||
);
|
||||
});
|
||||
|
||||
it("joins alternatives the way the language does", () => {
|
||||
const formats: ToolFormat[] = ["PDF", "IMAGE"];
|
||||
expect(getToolFormatListLabel(t, "en-US", formats)).toBe("a PDF or images");
|
||||
});
|
||||
|
||||
it("renders a single format without a conjunction", () => {
|
||||
expect(getToolFormatListLabel(t, "en-US", ["PDF"])).toBe("a PDF");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
import { type TFunction } from "i18next";
|
||||
import { type ToolFormat } from "@app/types/toolIO";
|
||||
|
||||
/**
|
||||
* A tool format as a phrase to show someone, not the enum name. `PDF_ENCRYPTED` is a wire value;
|
||||
* the reader sees "a password-protected PDF".
|
||||
*/
|
||||
export const getToolFormatLabel = (t: TFunction, format: ToolFormat): string =>
|
||||
t(`toolFormat.${format}`);
|
||||
|
||||
/**
|
||||
* Several formats as one phrase, joined the way the reader's language joins alternatives:
|
||||
* "a PDF or an image".
|
||||
*/
|
||||
export function getToolFormatListLabel(
|
||||
t: TFunction,
|
||||
language: string,
|
||||
formats: ToolFormat[],
|
||||
): string {
|
||||
const labels = formats.map((format) => getToolFormatLabel(t, format));
|
||||
return new Intl.ListFormat(language, {
|
||||
style: "long",
|
||||
type: "disjunction",
|
||||
}).format(labels);
|
||||
}
|
||||
@@ -7,6 +7,9 @@ import {
|
||||
type SubcategoryId,
|
||||
} from "@app/data/toolsTaxonomy";
|
||||
import { type ExecutableTool } from "@app/hooks/tools/shared/toolAutomation";
|
||||
import { toolAcceptsFormat } from "@app/utils/toolIOCompat";
|
||||
import { getToolFormatLabel } from "@app/utils/toolIOLabels";
|
||||
import { type ToolFormat } from "@app/types/toolIO";
|
||||
import {
|
||||
searchOperations,
|
||||
type StepOperation,
|
||||
@@ -24,6 +27,12 @@ interface ToolPickerProps {
|
||||
*/
|
||||
operations?: StepOperation[];
|
||||
onPickOperation?: (operation: StepOperation) => void;
|
||||
/**
|
||||
* What the step before this one produces, when known. Tools that cannot run on it are marked
|
||||
* rather than hidden: the chain is still editable in any order, and the builder explains the
|
||||
* problem once the step is added.
|
||||
*/
|
||||
precedingOutput?: ToolFormat;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -36,6 +45,7 @@ export function ToolPicker({
|
||||
onClose,
|
||||
operations = [],
|
||||
onPickOperation,
|
||||
precedingOutput,
|
||||
}: ToolPickerProps) {
|
||||
const { t } = useTranslation();
|
||||
const [query, setQuery] = useState("");
|
||||
@@ -114,6 +124,14 @@ export function ToolPicker({
|
||||
<span className="portal-pipelines__picker-name">
|
||||
{tool.name}
|
||||
</span>
|
||||
{precedingOutput &&
|
||||
!toolAcceptsFormat(tool.endpoint, precedingOutput) ? (
|
||||
<span className="portal-pipelines__picker-note">
|
||||
{t("portal.pipelines.builder.cannotFollow", {
|
||||
produced: getToolFormatLabel(t, precedingOutput),
|
||||
})}
|
||||
</span>
|
||||
) : null}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -164,6 +164,19 @@
|
||||
color: var(--c-text-subtle);
|
||||
}
|
||||
|
||||
/* A step that cannot run on what the one before it produces. */
|
||||
.portal-builder__step-note--danger {
|
||||
color: var(--c-danger);
|
||||
}
|
||||
|
||||
/* A picker entry that cannot run on what the chain currently produces. */
|
||||
.portal-pipelines__picker-note {
|
||||
margin-left: auto;
|
||||
padding-left: 0.5rem;
|
||||
font-size: 0.6875rem;
|
||||
color: var(--c-text-subtle);
|
||||
}
|
||||
|
||||
.portal-builder__step-actions {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
|
||||
@@ -111,7 +111,28 @@ vi.mock("@app/contexts/ToolRegistryContext", () => {
|
||||
fromApiParams: (params: Record<string, unknown>) => ({ ...params }),
|
||||
},
|
||||
} as unknown as ToolRegistryEntry;
|
||||
const allTools = { compress } as unknown as ToolRegistryCatalog["allTools"];
|
||||
// Produces images, so nothing downstream that wants a PDF can follow it.
|
||||
const extractImages = {
|
||||
name: "Extract images",
|
||||
icon: null,
|
||||
component: null,
|
||||
description: "",
|
||||
categoryId: "recommendedTools",
|
||||
subcategoryId: "general",
|
||||
operationConfig: {
|
||||
operationType: "extractImages",
|
||||
toolType: 0,
|
||||
endpoint: "/api/v1/misc/extract-images",
|
||||
defaultParameters: {},
|
||||
buildFormData: () => new FormData(),
|
||||
toApiParams: (params: Record<string, unknown>) => ({ ...params }),
|
||||
fromApiParams: (params: Record<string, unknown>) => ({ ...params }),
|
||||
},
|
||||
} as unknown as ToolRegistryEntry;
|
||||
const allTools = {
|
||||
compress,
|
||||
extractImages,
|
||||
} as unknown as ToolRegistryCatalog["allTools"];
|
||||
const catalog: ToolRegistryCatalog = {
|
||||
regularTools: allTools,
|
||||
superTools: allTools,
|
||||
@@ -260,6 +281,55 @@ describe("PipelineBuilder", () => {
|
||||
expect(await screen.findByText("pipelines list")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("blocks saving a chain whose steps can't run on each other", async () => {
|
||||
renderBuilder("/processor/pipelines/new");
|
||||
|
||||
fireEvent.change(
|
||||
await screen.findByRole("textbox", {
|
||||
name: "portal.pipelines.composer.name",
|
||||
}),
|
||||
{ target: { value: "Broken chain" } },
|
||||
);
|
||||
await pickInputSource("Claims intake");
|
||||
fireEvent.click(screen.getByText("pick output"));
|
||||
|
||||
// Extract images emits images; compress only takes a PDF, so it can never run.
|
||||
fireEvent.click(screen.getByRole("button", { name: /addTool/ }));
|
||||
fireEvent.click(await screen.findByText("Extract images"));
|
||||
fireEvent.click(screen.getByRole("button", { name: /addTool/ }));
|
||||
fireEvent.click(await screen.findByText("Compress"));
|
||||
|
||||
expect(
|
||||
await screen.findByText("portal.pipelines.builder.stepsIncompatible"),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText("portal.pipelines.composer.create").closest("button"),
|
||||
).toBeDisabled();
|
||||
});
|
||||
|
||||
it("allows a chain whose steps line up", async () => {
|
||||
renderBuilder("/processor/pipelines/new");
|
||||
|
||||
fireEvent.change(
|
||||
await screen.findByRole("textbox", {
|
||||
name: "portal.pipelines.composer.name",
|
||||
}),
|
||||
{ target: { value: "Fine chain" } },
|
||||
);
|
||||
await pickInputSource("Claims intake");
|
||||
fireEvent.click(screen.getByText("pick output"));
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /addTool/ }));
|
||||
fireEvent.click(await screen.findByText("Compress"));
|
||||
|
||||
expect(
|
||||
screen.queryByText("portal.pipelines.builder.stepsIncompatible"),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText("portal.pipelines.composer.create").closest("button"),
|
||||
).not.toBeDisabled();
|
||||
});
|
||||
|
||||
it("requires at least one source and one destination before saving", async () => {
|
||||
renderBuilder("/processor/pipelines/new");
|
||||
|
||||
|
||||
@@ -31,6 +31,17 @@ import {
|
||||
type ExecutableTool,
|
||||
type WorkingToolStep,
|
||||
} from "@app/hooks/tools/shared/toolAutomation";
|
||||
import {
|
||||
getToolFormatLabel,
|
||||
getToolFormatListLabel,
|
||||
} from "@app/utils/toolIOLabels";
|
||||
import {
|
||||
chainOutputFormat,
|
||||
diagnosticsForStep,
|
||||
hasBlockingDiagnostics,
|
||||
validateToolChain,
|
||||
type ToolDiagnostic,
|
||||
} from "@app/utils/toolIOCompat";
|
||||
import { errorMessage } from "@portal/api/http";
|
||||
import {
|
||||
deletePipeline,
|
||||
@@ -154,7 +165,7 @@ function buildTriggerFor(input: WorkingInput): TriggerConfig | null {
|
||||
* deletes it. Replaces the former modal composer and the list's inline detail card.
|
||||
*/
|
||||
export function PipelineBuilder() {
|
||||
const { t } = useTranslation();
|
||||
const { t, i18n } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
// Pipelines are stored as policies, so a save/delete must invalidate both the
|
||||
@@ -402,6 +413,66 @@ export function PipelineBuilder() {
|
||||
.map(stepLabel);
|
||||
const hasUnconfiguredSteps = unconfiguredStepLabels.length > 0;
|
||||
|
||||
// Whether each step can actually accept what the one before it produces. Checked here from the
|
||||
// generated tool I/O table rather than by running the pipeline, so an impossible chain (say,
|
||||
// Extract Images then Rotate) is caught while it is being built.
|
||||
const chainDiagnostics = useMemo(
|
||||
() =>
|
||||
validateToolChain(
|
||||
steps.map((step) => ({
|
||||
operation: step.operation,
|
||||
parameters: step.params,
|
||||
})),
|
||||
),
|
||||
[steps],
|
||||
);
|
||||
const blockingSteps = chainDiagnostics
|
||||
.filter((d) => d.severity === "ERROR")
|
||||
.map((d) => stepLabel(steps[d.stepIndex]));
|
||||
const hasIncompatibleSteps = hasBlockingDiagnostics(chainDiagnostics);
|
||||
|
||||
// What a newly added step would be handed, so the picker can flag tools that cannot take it.
|
||||
const chainOutput = useMemo(
|
||||
() =>
|
||||
chainOutputFormat(
|
||||
steps.map((step) => ({
|
||||
operation: step.operation,
|
||||
parameters: step.params,
|
||||
})),
|
||||
),
|
||||
[steps],
|
||||
);
|
||||
|
||||
function diagnosticNote(diagnostic: ToolDiagnostic): string {
|
||||
const { accepts, produced } = diagnostic.detail;
|
||||
return t(`portal.pipelines.builder.diagnostic.${diagnostic.code}`, {
|
||||
accepts: getToolFormatListLabel(t, i18n.language, accepts ?? []),
|
||||
produced: produced ? getToolFormatLabel(t, produced) : "",
|
||||
});
|
||||
}
|
||||
|
||||
/** The most severe diagnostic for a step, rendered as its note. */
|
||||
function renderStepDiagnostic(index: number) {
|
||||
const forStep = diagnosticsForStep(chainDiagnostics, index);
|
||||
const diagnostic =
|
||||
forStep.find((d) => d.severity === "ERROR") ??
|
||||
forStep.find((d) => d.severity === "WARN") ??
|
||||
forStep[0];
|
||||
if (!diagnostic) return null;
|
||||
return (
|
||||
<span
|
||||
className={
|
||||
"portal-builder__step-note" +
|
||||
(diagnostic.severity === "ERROR"
|
||||
? " portal-builder__step-note--danger"
|
||||
: "")
|
||||
}
|
||||
>
|
||||
{diagnosticNote(diagnostic)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// Track unsaved edits: snapshot the form and compare against the state captured just after
|
||||
// seeding, so leaving the builder can prompt to save or discard.
|
||||
const snapshot = JSON.stringify({
|
||||
@@ -430,6 +501,7 @@ export function PipelineBuilder() {
|
||||
outputValid &&
|
||||
!hasUploadSteps &&
|
||||
!hasUnconfiguredSteps &&
|
||||
!hasIncompatibleSteps &&
|
||||
!submitting;
|
||||
|
||||
const listPath = toPortalPath(VIEW_PATHS.pipelines);
|
||||
@@ -707,6 +779,14 @@ export function PipelineBuilder() {
|
||||
})}
|
||||
/>
|
||||
)}
|
||||
{hasIncompatibleSteps && (
|
||||
<Banner
|
||||
tone="danger"
|
||||
description={t("portal.pipelines.builder.stepsIncompatible", {
|
||||
tools: blockingSteps.join(", "),
|
||||
})}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Pipeline-level settings, above the operation list. */}
|
||||
<section className="portal-builder__settings">
|
||||
@@ -885,7 +965,9 @@ export function PipelineBuilder() {
|
||||
<span className="portal-builder__step-note">
|
||||
{t("portal.pipelines.builder.unknownStep")}
|
||||
</span>
|
||||
) : null}
|
||||
) : (
|
||||
renderStepDiagnostic(i)
|
||||
)}
|
||||
</span>
|
||||
</Button>
|
||||
<div className="portal-builder__step-actions">
|
||||
@@ -930,6 +1012,7 @@ export function PipelineBuilder() {
|
||||
onPick={addStep}
|
||||
operations={STEP_OPERATIONS}
|
||||
onPickOperation={addOperationStep}
|
||||
precedingOutput={chainOutput}
|
||||
onClose={() => setPickerOpen(false)}
|
||||
/>
|
||||
) : (
|
||||
|
||||
Reference in New Issue
Block a user