189 lines
6.1 KiB
TypeScript
189 lines
6.1 KiB
TypeScript
/**
|
|
* workflowPacking.js — Pack/unpack file assets into workflow JSON.
|
|
*
|
|
* Packed workflows embed base64-encoded file contents so they are
|
|
* portable across machines and sessions.
|
|
*/
|
|
|
|
import * as api from './api.ts';
|
|
import type { SerializedWorkflow, NodeDefsRegistry, InputSpec } from './types.ts';
|
|
|
|
const MAX_PACKED_BYTES = 100 * 1024 * 1024; // 100 MB
|
|
|
|
// ── Helpers ──────────────────────────────────────────────────────────
|
|
|
|
function arrayBufferToBase64(buffer: ArrayBuffer) {
|
|
const bytes = new Uint8Array(buffer);
|
|
let binary = '';
|
|
for (let i = 0; i < bytes.length; i++) {
|
|
binary += String.fromCharCode(bytes[i]);
|
|
}
|
|
return btoa(binary);
|
|
}
|
|
|
|
function base64ToUint8Array(b64: string) {
|
|
const binary = atob(b64);
|
|
const bytes = new Uint8Array(binary.length);
|
|
for (let i = 0; i < binary.length; i++) {
|
|
bytes[i] = binary.charCodeAt(i);
|
|
}
|
|
return bytes;
|
|
}
|
|
|
|
function getInputType(spec: InputSpec | null) {
|
|
if (!spec) return null;
|
|
const type = Array.isArray(spec) ? spec[0] : spec;
|
|
return Array.isArray(type) ? type[0] : type;
|
|
}
|
|
|
|
function filenameFromPath(path: string): string {
|
|
return String(path).split('/').pop() || path;
|
|
}
|
|
|
|
/**
|
|
* Extract the relative path portion from a session:// URI.
|
|
* e.g. "session://uploads/myfolder/scan.ibw" → "myfolder/scan.ibw"
|
|
*/
|
|
function sessionRelativePath(path: string) {
|
|
const prefix = 'session://uploads/';
|
|
if (path.startsWith(prefix)) return path.slice(prefix.length);
|
|
return filenameFromPath(path);
|
|
}
|
|
|
|
// ── Pack ─────────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Embed referenced files into workflowData.
|
|
*
|
|
* @param {object} workflowData - Serialized workflow (from serializeWorkflowState)
|
|
* @param {object} nodeDefs - Node definition registry (nodeDefsRef.current)
|
|
* @param {function} [onProgress] - Optional (packed, total) callback
|
|
* @returns {object} workflowData with packedFiles added
|
|
*/
|
|
export async function packWorkflow(workflowData: SerializedWorkflow, nodeDefs: NodeDefsRegistry, onProgress?: (packed: number, total: number) => void) {
|
|
// 1. Collect FILE_PICKER paths only (skip FOLDER_PICKER)
|
|
const filePaths = new Set<string>();
|
|
|
|
for (const node of workflowData.nodes) {
|
|
const className = node.data?.className;
|
|
const def = className ? nodeDefs[className] : null;
|
|
if (!def) continue;
|
|
|
|
const allInputs = { ...(def.input?.required || {}), ...(def.input?.optional || {}) };
|
|
const widgetValues = node.data?.widgetValues || {};
|
|
|
|
for (const [name, spec] of Object.entries(allInputs)) {
|
|
const type = getInputType(spec);
|
|
const value = String(widgetValues[name] || '').trim();
|
|
if (!value) continue;
|
|
|
|
if (type === 'FILE_PICKER') {
|
|
filePaths.add(value);
|
|
}
|
|
}
|
|
}
|
|
|
|
if (filePaths.size === 0) {
|
|
return workflowData;
|
|
}
|
|
|
|
// 3. Fetch each file and encode
|
|
const packedFiles: Record<string, { filename: string; data: string }> = {};
|
|
let totalBytes = 0;
|
|
let packed = 0;
|
|
const total = filePaths.size;
|
|
|
|
for (const path of filePaths) {
|
|
try {
|
|
const buffer = await api.getFileContent(path);
|
|
totalBytes += buffer.byteLength;
|
|
if (totalBytes > MAX_PACKED_BYTES) {
|
|
throw new Error(
|
|
`Packed workflow exceeds ${Math.round(MAX_PACKED_BYTES / 1024 / 1024)} MB limit ` +
|
|
`(${Math.round(totalBytes / 1024 / 1024)} MB so far). ` +
|
|
`Reduce the number or size of referenced files.`
|
|
);
|
|
}
|
|
packedFiles[path] = {
|
|
filename: filenameFromPath(path),
|
|
data: arrayBufferToBase64(buffer),
|
|
};
|
|
} catch (err) {
|
|
if ((err as Error).message.includes('limit')) throw err;
|
|
// File may not exist (e.g. cleared path) — skip
|
|
}
|
|
packed++;
|
|
if (onProgress) onProgress(packed, total);
|
|
}
|
|
|
|
if (Object.keys(packedFiles).length === 0) {
|
|
return workflowData;
|
|
}
|
|
|
|
return {
|
|
...workflowData,
|
|
packed: true,
|
|
packedFiles,
|
|
};
|
|
}
|
|
|
|
// ── Unpack ───────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Extract packed files from workflowData and upload them to the current session.
|
|
*
|
|
* @param {object} workflowData - Workflow data potentially containing packedFiles
|
|
* @returns {{ workflow: object, restoredPaths: Set<string> }}
|
|
*/
|
|
export async function unpackWorkflow(workflowData: SerializedWorkflow) {
|
|
const packedFiles = workflowData.packedFiles;
|
|
if (!packedFiles || Object.keys(packedFiles).length === 0) {
|
|
return { workflow: workflowData, restoredPaths: new Set() };
|
|
}
|
|
|
|
const pathMap: Record<string, string> = {}; // oldPath → newSessionPath
|
|
const restoredPaths = new Set<string>();
|
|
|
|
// 1. Upload each packed file
|
|
for (const [origPath, entry] of Object.entries(packedFiles)) {
|
|
const bytes = base64ToUint8Array(entry.data);
|
|
const file = new File([bytes], entry.filename);
|
|
const relativePath = sessionRelativePath(origPath);
|
|
|
|
try {
|
|
const result = await api.uploadFile(file, { relativePath });
|
|
const newPath = result.path;
|
|
pathMap[origPath] = newPath;
|
|
restoredPaths.add(newPath);
|
|
} catch {
|
|
// Upload failed — skip this file
|
|
}
|
|
}
|
|
|
|
// 2. Remap widget values in nodes
|
|
const updatedNodes = workflowData.nodes.map((node) => {
|
|
const wv = node.data?.widgetValues;
|
|
if (!wv) return node;
|
|
|
|
let changed = false;
|
|
const nextWv = { ...wv };
|
|
for (const [key, val] of Object.entries(nextWv)) {
|
|
if (typeof val === 'string' && pathMap[val]) {
|
|
nextWv[key] = pathMap[val];
|
|
changed = true;
|
|
}
|
|
}
|
|
|
|
if (!changed) return node;
|
|
return { ...node, data: { ...node.data, widgetValues: nextWv } };
|
|
});
|
|
|
|
// Strip packed data from the workflow to avoid storing it again on re-save
|
|
const { packedFiles: _, packed: __, ...cleanWorkflow } = workflowData;
|
|
|
|
return {
|
|
workflow: { ...cleanWorkflow, nodes: updatedNodes },
|
|
restoredPaths,
|
|
};
|
|
}
|