u
This commit is contained in:
@@ -106,4 +106,25 @@ export const vfs = {
|
|||||||
body: { oldPath: cleanOld, newPath: cleanNew },
|
body: { oldPath: cleanOld, newPath: cleanNew },
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/vfs/*path — run a `.applet.js` file from VFS.
|
||||||
|
* Path goes in the URL, run params in the JSON body.
|
||||||
|
* @param {string} path VFS-relative applet path (e.g. `demo.applet.js`)
|
||||||
|
* @param {{ input?: object, timeout?: number, max_output?: number }} [options]
|
||||||
|
* → 200 { ok, result, columns, rows, warnings?, stdout, consoleLines, durationMs, truncated? }
|
||||||
|
* `result` is a typed CSV string (`;`-separated, header row first), `columns` are the
|
||||||
|
* declared `{ name, type }` pairs, `rows` is the data-row count.
|
||||||
|
*/
|
||||||
|
runApplet(path, options = {}) {
|
||||||
|
const clean = String(path || '').replace(/^\/+/, '');
|
||||||
|
const body = {};
|
||||||
|
if (options.input !== undefined) body.input = options.input;
|
||||||
|
if (options.timeout !== undefined) body.timeout = options.timeout;
|
||||||
|
if (options.max_output !== undefined) body.max_output = options.max_output;
|
||||||
|
return http(`/api/vfs/${clean}`, {
|
||||||
|
method: 'POST',
|
||||||
|
body,
|
||||||
|
});
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import CircularProgress from '@mui/material/CircularProgress';
|
|||||||
import ArrowBackRoundedIcon from '@mui/icons-material/ArrowBackRounded';
|
import ArrowBackRoundedIcon from '@mui/icons-material/ArrowBackRounded';
|
||||||
import CloseRoundedIcon from '@mui/icons-material/CloseRounded';
|
import CloseRoundedIcon from '@mui/icons-material/CloseRounded';
|
||||||
import PlayArrowRoundedIcon from '@mui/icons-material/PlayArrowRounded';
|
import PlayArrowRoundedIcon from '@mui/icons-material/PlayArrowRounded';
|
||||||
|
import DownloadRoundedIcon from '@mui/icons-material/DownloadRounded';
|
||||||
import CodeRoundedIcon from '@mui/icons-material/CodeRounded';
|
import CodeRoundedIcon from '@mui/icons-material/CodeRounded';
|
||||||
import ChatBubbleOutlineRoundedIcon from '@mui/icons-material/ChatBubbleOutlineRounded';
|
import ChatBubbleOutlineRoundedIcon from '@mui/icons-material/ChatBubbleOutlineRounded';
|
||||||
import InputRoundedIcon from '@mui/icons-material/InputRounded';
|
import InputRoundedIcon from '@mui/icons-material/InputRounded';
|
||||||
@@ -16,36 +17,53 @@ import OutputRoundedIcon from '@mui/icons-material/OutputRounded';
|
|||||||
|
|
||||||
import VfsFileIcon from './VfsFileIcon';
|
import VfsFileIcon from './VfsFileIcon';
|
||||||
import VfsAppletForm from './VfsAppletForm';
|
import VfsAppletForm from './VfsAppletForm';
|
||||||
import { parseAppletSchemas, buildAppletDefaults } from './vfsUtils';
|
import { parseAppletSchemas, buildAppletDefaults, parseCsvRows } from './vfsUtils';
|
||||||
|
import { vfs as vfsApi } from '../../api';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @file VfsAppletRunner.jsx
|
* @file VfsAppletRunner.jsx
|
||||||
* @description UI shell for `*.apple.js` applet files (forms-only, no execution yet).
|
* @description Runner UI for applet files (`*.applet.js`).
|
||||||
* Shows input form (editable) and output form (read-only) parsed from
|
* Shows an editable input form (from the `// @applet-input-schema {...}` JSON Schema) and a
|
||||||
* `// @applet-input-schema {...}` / `// @applet-output-schema {...}` markers,
|
* read-only result table (from the `// @applet-output-schema name:type;…` typed CSV header).
|
||||||
* plus a Run button that intentionally does nothing for now.
|
* The Run button executes the applet via `POST /api/vfs/{path}`; the response carries the
|
||||||
|
* result as a typed CSV string plus `columns`, `rows` and optional `warnings`.
|
||||||
*/
|
*/
|
||||||
export default class VfsAppletRunner extends PureComponent {
|
export default class VfsAppletRunner extends PureComponent {
|
||||||
constructor(props) {
|
constructor(props) {
|
||||||
super(props);
|
super(props);
|
||||||
const { inputSchema, outputSchema } = parseAppletSchemas(props.content || '');
|
const { inputSchema, outputColumns } = parseAppletSchemas(props.content || '');
|
||||||
this.state = {
|
this.state = {
|
||||||
inputSchema,
|
inputSchema,
|
||||||
outputSchema,
|
outputColumns,
|
||||||
inputValues: buildAppletDefaults(inputSchema),
|
inputValues: buildAppletDefaults(inputSchema),
|
||||||
// Output preview starts with schema defaults (read-only, no execution yet).
|
isRunning: false,
|
||||||
outputValues: buildAppletDefaults(outputSchema),
|
runError: null,
|
||||||
|
runResult: null,
|
||||||
|
runColumns: null,
|
||||||
|
runRows: [],
|
||||||
|
runWarnings: [],
|
||||||
|
stdout: '',
|
||||||
|
durationMs: null,
|
||||||
|
truncated: false,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
componentDidUpdate(prevProps) {
|
componentDidUpdate(prevProps) {
|
||||||
if (prevProps.content !== this.props.content || prevProps.fileName !== this.props.fileName) {
|
if (prevProps.content !== this.props.content || prevProps.fileName !== this.props.fileName) {
|
||||||
const { inputSchema, outputSchema } = parseAppletSchemas(this.props.content || '');
|
const { inputSchema, outputColumns } = parseAppletSchemas(this.props.content || '');
|
||||||
this.setState({
|
this.setState({
|
||||||
inputSchema,
|
inputSchema,
|
||||||
outputSchema,
|
outputColumns,
|
||||||
inputValues: buildAppletDefaults(inputSchema),
|
inputValues: buildAppletDefaults(inputSchema),
|
||||||
outputValues: buildAppletDefaults(outputSchema),
|
isRunning: false,
|
||||||
|
runError: null,
|
||||||
|
runResult: null,
|
||||||
|
runColumns: null,
|
||||||
|
runRows: [],
|
||||||
|
runWarnings: [],
|
||||||
|
stdout: '',
|
||||||
|
durationMs: null,
|
||||||
|
truncated: false,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -54,12 +72,179 @@ export default class VfsAppletRunner extends PureComponent {
|
|||||||
this.setState({ inputValues: nextValues });
|
this.setState({ inputValues: nextValues });
|
||||||
};
|
};
|
||||||
|
|
||||||
// Intentionally a no-op: execution will be wired up in a later step.
|
/**
|
||||||
handleRun = () => {};
|
* Download the last run's CSV result as a file named after the applet.
|
||||||
|
*/
|
||||||
|
handleDownloadCsv = () => {
|
||||||
|
const { fileName } = this.props;
|
||||||
|
const { runResult } = this.state;
|
||||||
|
if (!runResult) return;
|
||||||
|
|
||||||
|
const base = String(fileName || 'applet').split('/').pop().replace(/\.applet\.js$/i, '');
|
||||||
|
const blob = new Blob([runResult], { type: 'text/csv;charset=utf-8' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = `${base || 'applet'}.csv`;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
document.body.removeChild(a);
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute the applet via `POST /api/vfs/{path}` and render the returned CSV table.
|
||||||
|
*/
|
||||||
|
handleRun = async () => {
|
||||||
|
const { fileName } = this.props;
|
||||||
|
const { inputValues, isRunning } = this.state;
|
||||||
|
if (!fileName || isRunning) return;
|
||||||
|
|
||||||
|
this.setState({ isRunning: true, runError: null, stdout: '', truncated: false });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await vfsApi.runApplet(fileName, { input: inputValues });
|
||||||
|
const csv = typeof res?.result === 'string' ? res.result : '';
|
||||||
|
// The response only carries the row COUNT, so read the cells back out of the CSV.
|
||||||
|
const parsed = parseCsvRows(csv);
|
||||||
|
const header = parsed.length > 0 ? parsed[0] : [];
|
||||||
|
const body = parsed.slice(1);
|
||||||
|
const columns = Array.isArray(res?.columns) && res.columns.length > 0
|
||||||
|
? res.columns
|
||||||
|
: header.map((name) => ({ name, type: 'string' }));
|
||||||
|
|
||||||
|
this.setState({
|
||||||
|
isRunning: false,
|
||||||
|
runResult: csv,
|
||||||
|
runColumns: columns,
|
||||||
|
runRows: body,
|
||||||
|
runWarnings: Array.isArray(res?.warnings) ? res.warnings : [],
|
||||||
|
stdout: res?.stdout || '',
|
||||||
|
durationMs: res?.durationMs ?? null,
|
||||||
|
truncated: Boolean(res?.truncated),
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
this.setState({
|
||||||
|
isRunning: false,
|
||||||
|
runError: err?.message || 'Ausführung fehlgeschlagen',
|
||||||
|
runResult: null,
|
||||||
|
runColumns: null,
|
||||||
|
runRows: [],
|
||||||
|
runWarnings: [],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
renderResultTable() {
|
||||||
|
const { runColumns, runRows, outputColumns } = this.state;
|
||||||
|
// Before the first run there are no result columns yet, so fall back to the columns
|
||||||
|
// declared in the file header — the table then shows its shape immediately.
|
||||||
|
const columns = runColumns && runColumns.length > 0 ? runColumns : outputColumns;
|
||||||
|
if (!columns || columns.length === 0) {
|
||||||
|
return (
|
||||||
|
<Typography variant="body2" color="text.secondary" sx={{ fontSize: '0.8rem' }}>
|
||||||
|
Kein @applet-output-schema im Dateikopf gefunden.
|
||||||
|
</Typography>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box sx={{ overflowX: 'auto', border: 1, borderColor: 'divider', borderRadius: 2 }}>
|
||||||
|
<Box
|
||||||
|
component="table"
|
||||||
|
sx={{
|
||||||
|
borderCollapse: 'collapse',
|
||||||
|
width: '100%',
|
||||||
|
fontFamily: 'Consolas, Monaco, "Courier New", monospace',
|
||||||
|
fontSize: '0.78rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box component="thead">
|
||||||
|
<Box component="tr" sx={{ bgcolor: 'action.hover' }}>
|
||||||
|
{columns.map((col) => (
|
||||||
|
<Box
|
||||||
|
component="th"
|
||||||
|
key={col.name}
|
||||||
|
sx={{
|
||||||
|
textAlign: 'left',
|
||||||
|
px: 1.25,
|
||||||
|
py: 0.75,
|
||||||
|
borderBottom: 1,
|
||||||
|
borderColor: 'divider',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography variant="caption" fontWeight={700} sx={{ fontSize: '0.75rem' }}>
|
||||||
|
{col.name}
|
||||||
|
</Typography>
|
||||||
|
<Typography
|
||||||
|
variant="caption"
|
||||||
|
color="text.disabled"
|
||||||
|
sx={{ display: 'block', fontSize: '0.65rem', fontWeight: 400 }}
|
||||||
|
>
|
||||||
|
{col.type}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
<Box component="tbody">
|
||||||
|
{runRows.length === 0 ? (
|
||||||
|
<Box component="tr">
|
||||||
|
<Box
|
||||||
|
component="td"
|
||||||
|
colSpan={columns.length}
|
||||||
|
sx={{ px: 1.25, py: 1.5, color: 'text.secondary', textAlign: 'center' }}
|
||||||
|
>
|
||||||
|
Noch nicht ausgeführt
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
) : (
|
||||||
|
runRows.map((row, rowIndex) => (
|
||||||
|
<Box
|
||||||
|
component="tr"
|
||||||
|
key={rowIndex}
|
||||||
|
sx={{ '&:nth-of-type(odd)': { bgcolor: 'action.hover' } }}
|
||||||
|
>
|
||||||
|
{columns.map((col, colIndex) => (
|
||||||
|
<Box
|
||||||
|
component="td"
|
||||||
|
key={col.name}
|
||||||
|
sx={{
|
||||||
|
px: 1.25,
|
||||||
|
py: 0.6,
|
||||||
|
borderBottom: 1,
|
||||||
|
borderColor: 'divider',
|
||||||
|
whiteSpace: 'pre-wrap',
|
||||||
|
wordBreak: 'break-word',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{row[colIndex] ?? ''}
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
const { fileName, file, loadingContent, onClose, onShowCode, onMentionInChat } = this.props;
|
const { fileName, file, loadingContent, onClose, onShowCode, onMentionInChat } = this.props;
|
||||||
const { inputSchema, outputSchema, inputValues, outputValues } = this.state;
|
const {
|
||||||
|
inputSchema,
|
||||||
|
outputColumns,
|
||||||
|
inputValues,
|
||||||
|
isRunning,
|
||||||
|
runError,
|
||||||
|
runResult,
|
||||||
|
runWarnings,
|
||||||
|
stdout,
|
||||||
|
durationMs,
|
||||||
|
truncated,
|
||||||
|
} = this.state;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
|
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
|
||||||
@@ -143,47 +328,105 @@ export default class VfsAppletRunner extends PureComponent {
|
|||||||
|
|
||||||
<Divider />
|
<Divider />
|
||||||
|
|
||||||
{/* Run Button (no-op for now) */}
|
{/* Run Button */}
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5, flexWrap: 'wrap' }}>
|
||||||
<Button
|
<Button
|
||||||
variant="contained"
|
variant="contained"
|
||||||
startIcon={<PlayArrowRoundedIcon />}
|
startIcon={isRunning ? <CircularProgress size={16} color="inherit" /> : <PlayArrowRoundedIcon />}
|
||||||
onClick={this.handleRun}
|
onClick={this.handleRun}
|
||||||
title="Ausführung folgt in einem späteren Schritt"
|
disabled={isRunning || !fileName}
|
||||||
sx={{ borderRadius: 2, fontWeight: 700 }}
|
sx={{ borderRadius: 2, fontWeight: 700 }}
|
||||||
>
|
>
|
||||||
Ausführen
|
{isRunning ? 'Läuft…' : 'Ausführen'}
|
||||||
</Button>
|
</Button>
|
||||||
<Typography variant="caption" color="text.secondary">
|
{durationMs != null && !isRunning && (
|
||||||
Führt das Applet noch nicht aus (nur Formular-Vorschau).
|
<Typography variant="caption" color="text.secondary">
|
||||||
</Typography>
|
{durationMs} ms
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
{truncated && (
|
||||||
|
<Typography variant="caption" color="warning.main">
|
||||||
|
stdout gekürzt
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
|
{/* Run error */}
|
||||||
|
{runError && (
|
||||||
|
<Alert severity="error" sx={{ fontSize: '0.8rem' }}>
|
||||||
|
{runError}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* stdout (only when the applet logged something) */}
|
||||||
|
{stdout && (
|
||||||
|
<Box>
|
||||||
|
<Typography variant="caption" fontWeight={700} color="text.secondary">
|
||||||
|
Konsole
|
||||||
|
</Typography>
|
||||||
|
<Box
|
||||||
|
component="pre"
|
||||||
|
sx={{
|
||||||
|
m: 0,
|
||||||
|
mt: 0.5,
|
||||||
|
p: 1.5,
|
||||||
|
borderRadius: 2,
|
||||||
|
bgcolor: (theme) => (theme.palette.mode === 'dark' ? '#0f1215' : '#f0f2f5'),
|
||||||
|
fontFamily: 'Consolas, Monaco, "Courier New", monospace',
|
||||||
|
fontSize: '0.75rem',
|
||||||
|
lineHeight: 1.5,
|
||||||
|
whiteSpace: 'pre-wrap',
|
||||||
|
wordBreak: 'break-word',
|
||||||
|
maxHeight: 220,
|
||||||
|
overflow: 'auto',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{stdout}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
<Divider />
|
<Divider />
|
||||||
|
|
||||||
{/* Output Section (read-only preview) */}
|
{/* Output Section (typed CSV result table) */}
|
||||||
<Box>
|
<Box>
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5 }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5 }}>
|
||||||
<OutputRoundedIcon color="action" sx={{ fontSize: 20 }} />
|
<OutputRoundedIcon color="action" sx={{ fontSize: 20 }} />
|
||||||
<Typography variant="subtitle2" fontWeight={700}>
|
<Typography variant="subtitle2" fontWeight={700}>
|
||||||
Ergebnis
|
Ergebnis
|
||||||
</Typography>
|
</Typography>
|
||||||
|
{runResult && (
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
variant="outlined"
|
||||||
|
startIcon={<DownloadRoundedIcon />}
|
||||||
|
onClick={this.handleDownloadCsv}
|
||||||
|
sx={{ ml: 'auto', borderRadius: 2, fontSize: '0.75rem', fontWeight: 600 }}
|
||||||
|
>
|
||||||
|
CSV herunterladen
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
<VfsAppletForm
|
|
||||||
schema={outputSchema}
|
{/* Warnings: cells dropped for not matching their declared type. */}
|
||||||
values={outputValues}
|
{runWarnings.length > 0 && (
|
||||||
readOnly
|
<Alert severity="warning" sx={{ fontSize: '0.78rem', mb: 1.5 }}>
|
||||||
emptyHint="Kein @applet-output-schema im Dateikopf gefunden."
|
{runWarnings.map((warning) => (
|
||||||
/>
|
<div key={warning}>{warning}</div>
|
||||||
|
))}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{this.renderResultTable()}
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{!inputSchema && !outputSchema && (
|
{!inputSchema && !outputColumns && (
|
||||||
<Alert severity="info" sx={{ fontSize: '0.8rem' }}>
|
<Alert severity="info" sx={{ fontSize: '0.8rem' }}>
|
||||||
In dieser Datei wurden keine Applet-Schemas gefunden. Erwartet werden Kopfzeilen wie
|
In dieser Datei wurden keine Applet-Schemas gefunden. Erwartet werden Kopfzeilen wie
|
||||||
<Box component="code" sx={{ display: 'block', mt: 0.5, fontFamily: 'monospace', fontSize: '0.72rem' }}>
|
<Box component="code" sx={{ display: 'block', mt: 0.5, fontFamily: 'monospace', fontSize: '0.72rem' }}>
|
||||||
{'// @applet-input-schema {"type":"object","properties":{...}}'}
|
{'// @applet-input-schema {"type":"object","properties":{...}}'}
|
||||||
<br />
|
<br />
|
||||||
{'// @applet-output-schema {"type":"object","properties":{...}}'}
|
{'// @applet-output-schema id:integer;name:string;active:boolean'}
|
||||||
</Box>
|
</Box>
|
||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -82,13 +82,13 @@ export function getVfsCategory(filePath, mime = '') {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Check whether a VFS path is an executable applet file.
|
* Check whether a VFS path is an executable applet file.
|
||||||
* Applets are `*.apple.js` (also accepts `*.applet.js`).
|
* Applets are `*.applet.js`.
|
||||||
* @param {string} filePath
|
* @param {string} filePath
|
||||||
* @returns {boolean}
|
* @returns {boolean}
|
||||||
*/
|
*/
|
||||||
export function isAppletFile(filePath) {
|
export function isAppletFile(filePath) {
|
||||||
const p = String(filePath || '').toLowerCase();
|
const p = String(filePath || '').toLowerCase();
|
||||||
return p.endsWith('.apple.js') || p.endsWith('.applet.js');
|
return p.endsWith('.applet.js');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -109,40 +109,136 @@ export function splitVfsPath(path) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Extract a JSON schema object from a `// @applet-*-schema {...}` marker line.
|
* Column types an applet may declare in its `@applet-output-schema` header.
|
||||||
* @param {string} content
|
* Mirrors `APPLET_COLUMN_TYPES` in `dashback/src/tools/javascript.js`.
|
||||||
* @param {'input'|'output'} kind
|
|
||||||
* @returns {object|null}
|
|
||||||
*/
|
*/
|
||||||
function extractAppletSchema(content, kind) {
|
export const APPLET_COLUMN_TYPES = ['string', 'integer', 'number', 'boolean', 'date'];
|
||||||
const marker = kind === 'input' ? '@applet-input-schema' : '@applet-output-schema';
|
|
||||||
const lines = String(content || '').split('\n');
|
/** Semicolon-separated columns, matching the backend and the `*Csv.js` spreadsheet exports. */
|
||||||
for (const line of lines) {
|
export const APPLET_CSV_SEPARATOR = ';';
|
||||||
const idx = line.indexOf(marker);
|
|
||||||
if (idx === -1) continue;
|
/**
|
||||||
const jsonPart = line.slice(idx + marker.length).trim();
|
* Parse an applet output header into typed CSV columns.
|
||||||
if (!jsonPart) continue;
|
*
|
||||||
try {
|
* The header carries column names AND types, e.g. `id:integer;name:string;active:boolean`.
|
||||||
const parsed = JSON.parse(jsonPart);
|
* A bare `name` (no `:type`) defaults to `string`. Returns `null` when the header is empty,
|
||||||
if (parsed && typeof parsed === 'object') return parsed;
|
* declares an unknown type, repeats a name, or has an empty column name — the same cases the
|
||||||
} catch {
|
* backend rejects with a `ToolError`.
|
||||||
// ignore malformed schema lines
|
*
|
||||||
}
|
* @param {string} header Raw text after the `// @applet-output-schema ` marker.
|
||||||
|
* @returns {Array<{ name: string, type: string }>|null}
|
||||||
|
*/
|
||||||
|
export function parseTypedCsvColumns(header) {
|
||||||
|
const text = String(header || '').trim();
|
||||||
|
if (!text) return null;
|
||||||
|
|
||||||
|
const columns = [];
|
||||||
|
const seen = new Set();
|
||||||
|
for (const raw of text.split(APPLET_CSV_SEPARATOR)) {
|
||||||
|
const field = raw.trim();
|
||||||
|
if (!field) continue;
|
||||||
|
|
||||||
|
const sep = field.lastIndexOf(':');
|
||||||
|
const name = (sep === -1 ? field : field.slice(0, sep)).trim();
|
||||||
|
const type = (sep === -1 ? 'string' : field.slice(sep + 1).trim().toLowerCase());
|
||||||
|
|
||||||
|
if (!name) return null;
|
||||||
|
if (!APPLET_COLUMN_TYPES.includes(type)) return null;
|
||||||
|
if (seen.has(name)) return null;
|
||||||
|
seen.add(name);
|
||||||
|
columns.push({ name, type });
|
||||||
}
|
}
|
||||||
return null;
|
|
||||||
|
return columns.length > 0 ? columns : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Parse applet input/output JSON schemas from file content.
|
* Parse a `;`-separated CSV string (RFC 4180 quoting) into a 2D array of raw cell strings.
|
||||||
* Looks for `// @applet-input-schema {...}` and `// @applet-output-schema {...}`.
|
*
|
||||||
* @param {string} content
|
* Used to render the applet's `result` table; the response only carries the row COUNT, so the
|
||||||
* @returns {{ inputSchema: object|null, outputSchema: object|null }}
|
* cells themselves have to be read back out of the CSV string.
|
||||||
|
*
|
||||||
|
* @param {string} text CSV text, header row first.
|
||||||
|
* @param {string} [separator=';']
|
||||||
|
* @returns {string[][]}
|
||||||
|
*/
|
||||||
|
export function parseCsvRows(text, separator = APPLET_CSV_SEPARATOR) {
|
||||||
|
const source = String(text ?? '');
|
||||||
|
if (!source) return [];
|
||||||
|
|
||||||
|
const rows = [];
|
||||||
|
let row = [];
|
||||||
|
let field = '';
|
||||||
|
let inQuotes = false;
|
||||||
|
|
||||||
|
for (let i = 0; i < source.length; i += 1) {
|
||||||
|
const ch = source[i];
|
||||||
|
if (inQuotes) {
|
||||||
|
if (ch === '"') {
|
||||||
|
if (source[i + 1] === '"') {
|
||||||
|
field += '"';
|
||||||
|
i += 1;
|
||||||
|
} else {
|
||||||
|
inQuotes = false;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
field += ch;
|
||||||
|
}
|
||||||
|
} else if (ch === '"') {
|
||||||
|
inQuotes = true;
|
||||||
|
} else if (ch === separator) {
|
||||||
|
row.push(field);
|
||||||
|
field = '';
|
||||||
|
} else if (ch === '\n') {
|
||||||
|
row.push(field);
|
||||||
|
rows.push(row);
|
||||||
|
row = [];
|
||||||
|
field = '';
|
||||||
|
} else if (ch !== '\r') {
|
||||||
|
field += ch;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Flush the trailing field/row, but ignore the empty row a final newline would produce.
|
||||||
|
if (field !== '' || row.length > 0) {
|
||||||
|
row.push(field);
|
||||||
|
rows.push(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse applet metadata from the file header.
|
||||||
|
*
|
||||||
|
* Mirrors the backend's `readApplet()`: the input JSON Schema must be on line 1 and the typed
|
||||||
|
* CSV output columns on line 2. Parsing the same two lines keeps the UI in lockstep with what
|
||||||
|
* the sandbox will actually accept.
|
||||||
|
*
|
||||||
|
* @param {string} content Applet file source.
|
||||||
|
* @returns {{ inputSchema: object|null, outputColumns: Array<{name: string, type: string}>|null }}
|
||||||
*/
|
*/
|
||||||
export function parseAppletSchemas(content) {
|
export function parseAppletSchemas(content) {
|
||||||
return {
|
const lines = String(content || '').replace(/^\uFEFF/, '').split(/\r?\n/);
|
||||||
inputSchema: extractAppletSchema(content, 'input'),
|
|
||||||
outputSchema: extractAppletSchema(content, 'output'),
|
let inputSchema = null;
|
||||||
};
|
const inputMatch = lines[0]?.match(/^\/\/ @applet-input-schema (.+)$/);
|
||||||
|
if (inputMatch) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(inputMatch[1]);
|
||||||
|
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) inputSchema = parsed;
|
||||||
|
} catch {
|
||||||
|
// Malformed input schema: leave null so the UI surfaces the hint.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let outputColumns = null;
|
||||||
|
const outputMatch = lines[1]?.match(/^\/\/ @applet-output-schema (.+)$/);
|
||||||
|
if (outputMatch) {
|
||||||
|
outputColumns = parseTypedCsvColumns(outputMatch[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { inputSchema, outputColumns };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in New Issue
Block a user