Initial Script Editing Functionality

This commit is contained in:
2025-09-03 03:03:27 -06:00
parent 5bc6e1104a
commit 0d5ba0056f
3 changed files with 956 additions and 2 deletions

View File

@@ -26,7 +26,7 @@ import StatusBar from "./Status_Bar";
import NavigationSidebar from "./Navigation_Sidebar";
import WorkflowList from "./Workflows/Workflow_List";
import DeviceList from "./Devices/Device_List";
import ScriptList from "./Scripting/Script_List";
import ScriptEditor from "./Scripting/Script_Editor";
import ScheduledJobsList from "./Scheduling/Scheduled_Jobs_List";
import Login from "./Login.jsx";
import DeviceDetails from "./Devices/Device_Details";
@@ -371,7 +371,7 @@ export default function App() {
);
case "scripts":
return <ScriptList />;
return <ScriptEditor />;
case "workflow-editor":
return (

View File

@@ -0,0 +1,693 @@
////////// PROJECT FILE SEPARATION LINE ////////// CODE AFTER THIS LINE ARE FROM: <ProjectRoot>/Data/WebUI/src/Scripting/Script_Editor.jsx
import React, { useState, useEffect, useCallback, useMemo } from "react";
import {
Paper,
Box,
Typography,
Button,
Menu,
MenuItem,
Select,
FormControl,
InputLabel,
TextField
} from "@mui/material";
import { Folder as FolderIcon, Description as DescriptionIcon } from "@mui/icons-material";
import {
SimpleTreeView,
TreeItem,
useTreeViewApiRef
} from "@mui/x-tree-view";
// Reuse shared dialogs for consistency
import { ConfirmDeleteDialog, RenameFolderDialog } from "../Dialogs";
// Prism for syntax highlighting (same theme as JSON node)
import Prism from "prismjs";
import "prismjs/components/prism-yaml";
import "prismjs/components/prism-bash";
import "prismjs/components/prism-powershell";
import "prismjs/components/prism-batch";
import "prismjs/themes/prism-okaidia.css";
// ---------- helpers ----------
const TYPE_OPTIONS = [
{ key: "ansible", label: "Ansible Playbook", ext: ".yml", prism: "yaml" },
{ key: "powershell", label: "Powershell Script", ext: ".ps1", prism: "powershell" },
{ key: "batch", label: "Batch Script", ext: ".bat", prism: "batch" },
{ key: "bash", label: "Bash Script", ext: ".sh", prism: "bash" }
];
const keyBy = (arr) => Object.fromEntries(arr.map((o) => [o.key, o]));
const TYPES = keyBy(TYPE_OPTIONS);
function typeFromFilename(name = "") {
const n = name.toLowerCase();
if (n.endsWith(".yml")) return "ansible";
if (n.endsWith(".ps1")) return "powershell";
if (n.endsWith(".bat")) return "batch";
if (n.endsWith(".sh")) return "bash";
return "ansible"; // default
}
function ensureExt(baseName, typeKey) {
const t = TYPES[typeKey] || TYPES.ansible;
const bn = baseName.replace(/\.(yml|ps1|bat|sh)$/i, "");
return bn + t.ext;
}
function buildTree(scripts, folders) {
const map = {};
const rootNode = {
id: "root",
label: "Scripts",
path: "",
isFolder: true,
children: []
};
map[rootNode.id] = rootNode;
(folders || []).forEach((f) => {
const parts = (f || "").split("/");
let children = rootNode.children;
let parentPath = "";
parts.forEach((part) => {
const path = parentPath ? `${parentPath}/${part}` : part;
let node = children.find((n) => n.id === path);
if (!node) {
node = { id: path, label: part, path, isFolder: true, children: [] };
children.push(node);
map[path] = node;
}
children = node.children;
parentPath = path;
});
});
(scripts || []).forEach((s) => {
const parts = (s.rel_path || "").split("/");
let children = rootNode.children;
let parentPath = "";
parts.forEach((part, idx) => {
const path = parentPath ? `${parentPath}/${part}` : part;
const isFile = idx === parts.length - 1;
let node = children.find((n) => n.id === path);
if (!node) {
node = {
id: path,
label: isFile ? s.file_name : part,
path,
isFolder: !isFile,
fileName: s.file_name,
script: isFile ? s : null,
children: []
};
children.push(node);
map[path] = node;
}
if (!isFile) {
children = node.children;
parentPath = path;
}
});
});
return { root: [rootNode], map };
}
function highlightedHtml(code, prismLang) {
try {
const grammar = Prism.languages[prismLang] || Prism.languages.markup;
return Prism.highlight(code ?? "", grammar, prismLang);
} catch {
return (code ?? "").replace(/[&<>]/g, (c) => ({"&":"&amp;","<":"&lt;",">":"&gt;"}[c]));
}
}
// Local dialog: rename script file
function RenameScriptDialog({ open, value, onChange, onCancel, onSave }) {
return (
<Paper
component={(props) => (
<div {...props} />
)}
sx={{ display: open ? "block" : "none" }}
>
<div
style={{
position: "fixed",
inset: 0,
background: "rgba(0,0,0,0.4)",
display: "flex",
alignItems: "center",
justifyContent: "center",
zIndex: 9999
}}
>
<Paper sx={{ bgcolor: "#121212", color: "#fff", p: 2, minWidth: 360 }}>
<Typography variant="h6" sx={{ mb: 1 }}>Rename Script</Typography>
<TextField
autoFocus
margin="dense"
label="File Name"
fullWidth
variant="outlined"
value={value}
onChange={(e) => onChange(e.target.value)}
sx={{
"& .MuiOutlinedInput-root": {
backgroundColor: "#2a2a2a",
color: "#ccc",
"& fieldset": { borderColor: "#444" },
"&:hover fieldset": { borderColor: "#666" }
},
label: { color: "#aaa" },
mt: 1
}}
/>
<Box sx={{ display: "flex", justifyContent: "flex-end", mt: 2 }}>
<Button onClick={onCancel} sx={{ color: "#58a6ff" }}>Cancel</Button>
<Button onClick={onSave} sx={{ color: "#58a6ff" }}>Save</Button>
</Box>
</Paper>
</div>
</Paper>
);
}
// Local dialog: new script (name + type)
function NewScriptDialog({ open, name, type, onChangeName, onChangeType, onCancel, onCreate }) {
return (
<Paper component={(p) => <div {...p} />} sx={{ display: open ? "block" : "none" }}>
<div
style={{
position: "fixed",
inset: 0,
background: "rgba(0,0,0,0.4)",
display: "flex",
alignItems: "center",
justifyContent: "center",
zIndex: 9999
}}
>
<Paper sx={{ bgcolor: "#121212", color: "#fff", p: 2, minWidth: 360 }}>
<Typography variant="h6" sx={{ mb: 1 }}>New Script</Typography>
<TextField
autoFocus
margin="dense"
label="File Name"
fullWidth
variant="outlined"
value={name}
onChange={(e) => onChangeName(e.target.value)}
sx={{
"& .MuiOutlinedInput-root": {
backgroundColor: "#2a2a2a",
color: "#ccc",
"& fieldset": { borderColor: "#444" },
"&:hover fieldset": { borderColor: "#666" }
},
label: { color: "#aaa" },
mt: 1
}}
/>
<FormControl fullWidth sx={{ mt: 2 }}>
<InputLabel sx={{ color: "#aaa" }}>Type</InputLabel>
<Select
value={type}
label="Type"
onChange={(e) => onChangeType(e.target.value)}
sx={{
color: "#e6edf3",
bgcolor: "#1e1e1e",
"& .MuiOutlinedInput-notchedOutline": { borderColor: "#444" },
"&:hover .MuiOutlinedInput-notchedOutline": { borderColor: "#666" }
}}
>
{TYPE_OPTIONS.map((o) => (
<MenuItem key={o.key} value={o.key}>{o.label}</MenuItem>
))}
</Select>
</FormControl>
<Box sx={{ display: "flex", justifyContent: "flex-end", mt: 2 }}>
<Button onClick={onCancel} sx={{ color: "#58a6ff" }}>Cancel</Button>
<Button onClick={onCreate} sx={{ color: "#58a6ff" }}>Create</Button>
</Box>
</Paper>
</div>
</Paper>
);
}
export default function ScriptEditor() {
// Tree state
const [tree, setTree] = useState([]);
const [nodeMap, setNodeMap] = useState({});
const [contextMenu, setContextMenu] = useState(null);
const [selectedNode, setSelectedNode] = useState(null);
const apiRef = useTreeViewApiRef();
const [dragNode, setDragNode] = useState(null);
// Editor state
const [currentPath, setCurrentPath] = useState("");
const [currentFolder, setCurrentFolder] = useState("");
const [fileName, setFileName] = useState("");
const [type, setType] = useState("ansible");
const [code, setCode] = useState("");
// Dialog state
const [renameOpen, setRenameOpen] = useState(false);
const [renameValue, setRenameValue] = useState("");
const [renameFolderOpen, setRenameFolderOpen] = useState(false);
const [folderDialogMode, setFolderDialogMode] = useState("rename");
const [newScriptOpen, setNewScriptOpen] = useState(false);
const [newScriptName, setNewScriptName] = useState("");
const [newScriptType, setNewScriptType] = useState("ansible");
const [deleteOpen, setDeleteOpen] = useState(false);
const prismLang = useMemo(() => (TYPES[type]?.prism || "yaml"), [type]);
const loadTree = useCallback(async () => {
try {
const resp = await fetch("/api/scripts/list");
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
const data = await resp.json();
const { root, map } = buildTree(data.scripts || [], data.folders || []);
setTree(root);
setNodeMap(map);
} catch (err) {
console.error("Failed to load scripts:", err);
setTree([]);
setNodeMap({});
}
}, []);
useEffect(() => { loadTree(); }, [loadTree]);
// Context menu
const handleContextMenu = (e, node) => {
e.preventDefault();
setSelectedNode(node);
setContextMenu(
contextMenu === null
? { mouseX: e.clientX - 2, mouseY: e.clientY - 4 }
: null
);
};
const handleDrop = async (target) => {
if (!dragNode || !target.isFolder) return;
// Prevent dropping into itself or its descendants
if (dragNode.path === target.path || target.path.startsWith(`${dragNode.path}/`)) {
setDragNode(null);
return;
}
const newPath = target.path ? `${target.path}/${dragNode.fileName}` : dragNode.fileName;
try {
await fetch("/api/scripts/move_file", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ path: dragNode.path, new_path: newPath })
});
loadTree();
} catch (err) {
console.error("Failed to move:", err);
}
setDragNode(null);
};
const handleNodeSelect = async (_e, itemId) => {
const node = nodeMap[itemId];
if (node && !node.isFolder) {
try {
const resp = await fetch(`/api/scripts/load?path=${encodeURIComponent(node.path)}`);
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
const data = await resp.json();
const folder = node.path.split("/").slice(0, -1).join("/");
setCurrentFolder(folder);
setCurrentPath(node.path);
setFileName(data.file_name || node.fileName || node.label);
setType(typeFromFilename(data.file_name || node.fileName || node.label));
setCode(data.content || "");
} catch (err) {
console.error("Failed to load script:", err);
}
}
};
const saveScript = async () => {
if (!currentPath && !fileName) {
// prompt for name/type first if user started typing without creating
setNewScriptName("");
setNewScriptType(type);
setNewScriptOpen(true);
return;
}
const payload = {
path: currentPath || undefined,
name: currentPath ? undefined : fileName,
content: code,
type
};
try {
const resp = await fetch("/api/scripts/save", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload)
});
const data = await resp.json();
if (!resp.ok) throw new Error(data?.error || `HTTP ${resp.status}`);
// Update state with normalized rel_path (may include ext changes)
if (data.rel_path) {
setCurrentPath(data.rel_path);
const fname = data.rel_path.split("/").pop();
setFileName(fname);
setType(typeFromFilename(fname));
// ensure tree refresh
loadTree();
}
} catch (err) {
console.error("Failed to save script:", err);
}
};
const handleRename = () => {
setContextMenu(null);
if (!selectedNode) return;
setRenameValue(selectedNode.label);
if (selectedNode.isFolder) {
setFolderDialogMode("rename");
setRenameFolderOpen(true);
} else setRenameOpen(true);
};
const handleNewFolder = () => {
if (!selectedNode) return;
setContextMenu(null);
setFolderDialogMode("create");
setRenameValue("");
setRenameFolderOpen(true);
};
const handleNewScript = () => {
if (!selectedNode) return;
setContextMenu(null);
setNewScriptName("");
setNewScriptType("ansible");
setNewScriptOpen(true);
};
const saveRenameFile = async () => {
try {
const res = await fetch("/api/scripts/rename_file", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ path: selectedNode.path, new_name: renameValue, type })
});
const data = await res.json();
if (!res.ok) throw new Error(data?.error || `HTTP ${res.status}`);
if (currentPath === selectedNode.path) {
setCurrentPath(data.rel_path || selectedNode.path);
const fname = (data.rel_path || selectedNode.path).split("/").pop();
setFileName(fname);
setType(typeFromFilename(fname));
}
setRenameOpen(false);
loadTree();
} catch (err) {
console.error("Failed to rename file:", err);
setRenameOpen(false);
}
};
const saveRenameFolder = async () => {
try {
if (folderDialogMode === "rename" && selectedNode) {
await fetch("/api/scripts/rename_folder", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ path: selectedNode.path, new_name: renameValue })
});
} else {
const basePath = selectedNode ? selectedNode.path : "";
const newPath = basePath ? `${basePath}/${renameValue}` : renameValue;
await fetch("/api/scripts/create_folder", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ path: newPath })
});
}
setRenameFolderOpen(false);
loadTree();
} catch (err) {
console.error("Folder operation failed:", err);
setRenameFolderOpen(false);
}
};
const confirmDelete = async () => {
if (!selectedNode) return;
try {
if (selectedNode.isFolder) {
await fetch("/api/scripts/delete_folder", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ path: selectedNode.path })
});
} else {
await fetch("/api/scripts/delete_file", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ path: selectedNode.path })
});
if (currentPath === selectedNode.path) {
setCurrentPath("");
setFileName("");
setCode("");
}
}
setDeleteOpen(false);
loadTree();
} catch (err) {
console.error("Failed to delete:", err);
setDeleteOpen(false);
}
};
const createNewScript = () => {
const folder = selectedNode?.isFolder
? selectedNode.path
: (selectedNode?.path?.split("/").slice(0, -1).join("/") || currentFolder || "");
const finalName = ensureExt(newScriptName || "script", newScriptType);
const newPath = folder ? `${folder}/${finalName}` : finalName;
setCurrentFolder(folder);
setCurrentPath(newPath);
setFileName(finalName);
setType(newScriptType);
setCode("");
setNewScriptOpen(false);
// Note: not saved until user clicks Save
};
const rootChildIds = tree[0]?.children?.map((c) => c.id) || [];
const highlighted = useMemo(() => highlightedHtml(code, prismLang), [code, prismLang]);
const renderItems = (nodes) =>
(nodes || []).map((n) => (
<TreeItem
key={n.id}
itemId={n.id}
label={
<Box
sx={{ display: "flex", alignItems: "center" }}
draggable={!n.isFolder}
onDragStart={() => !n.isFolder && setDragNode(n)}
onDragOver={(e) => { if (dragNode && n.isFolder) e.preventDefault(); }}
onDrop={(e) => { e.preventDefault(); handleDrop(n); }}
onContextMenu={(e) => handleContextMenu(e, n)}
>
{n.isFolder ? (
<FolderIcon sx={{ mr: 1, color: "#0475c2" }} />
) : (
<DescriptionIcon sx={{ mr: 1, color: "#0475c2" }} />
)}
<Typography sx={{ flexGrow: 1, color: "#e6edf3" }}>{n.label}</Typography>
</Box>
}
>
{n.children && n.children.length > 0 ? renderItems(n.children) : null}
</TreeItem>
));
return (
<Box sx={{ display: "flex", flex: 1, height: "100%", overflow: "hidden" }}>
{/* Left: Tree */}
<Paper sx={{ m: 2, p: 0, bgcolor: "#1e1e1e", width: 360, flexShrink: 0 }} elevation={2}>
<Box sx={{ p: 2, pb: 1 }}>
<Typography variant="h6" sx={{ color: "#58a6ff", mb: 0 }}>Scripts</Typography>
<Typography variant="body2" sx={{ color: "#aaa" }}>
Create, edit, and rearrange scripts in folders.
</Typography>
</Box>
<Box
sx={{ p: 2 }}
onDragOver={(e) => { if (dragNode) e.preventDefault(); }}
onDrop={(e) => { e.preventDefault(); handleDrop({ path: "", isFolder: true }); }}
>
<SimpleTreeView
key={rootChildIds.join(",")}
sx={{ color: "#e6edf3" }}
onNodeSelect={handleNodeSelect}
apiRef={apiRef}
defaultExpandedItems={["root", ...rootChildIds]}
>
{renderItems(tree)}
</SimpleTreeView>
</Box>
<Menu
open={contextMenu !== null}
onClose={() => setContextMenu(null)}
anchorReference="anchorPosition"
anchorPosition={contextMenu ? { top: contextMenu.mouseY, left: contextMenu.mouseX } : undefined}
PaperProps={{ sx: { bgcolor: "#1e1e1e", color: "#fff", fontSize: "13px" } }}
>
{selectedNode?.isFolder && (
<>
<MenuItem onClick={handleNewScript}>New Script</MenuItem>
<MenuItem onClick={handleNewFolder}>New Subfolder</MenuItem>
{selectedNode.id !== "root" && (
<MenuItem onClick={handleRename}>Rename</MenuItem>
)}
{selectedNode.id !== "root" && (
<MenuItem onClick={() => { setContextMenu(null); setDeleteOpen(true); }}>Delete</MenuItem>
)}
</>
)}
{!selectedNode?.isFolder && (
<>
<MenuItem onClick={() => { setContextMenu(null); handleNodeSelect(null, selectedNode.id); }}>Edit</MenuItem>
<MenuItem onClick={handleRename}>Rename</MenuItem>
<MenuItem onClick={() => { setContextMenu(null); setDeleteOpen(true); }}>Delete</MenuItem>
</>
)}
</Menu>
</Paper>
{/* Right: Editor */}
<Paper sx={{ m: 2, p: 2, bgcolor: "#1e1e1e", display: "flex", flexDirection: "column", flex: 1 }} elevation={2}>
<Box sx={{ display: "flex", alignItems: "center", gap: 2, mb: 2 }}>
<FormControl size="small" sx={{ minWidth: 220 }}>
<InputLabel sx={{ color: "#aaa" }}>Type</InputLabel>
<Select
value={type}
label="Type"
onChange={(e) => setType(e.target.value)}
sx={{
color: "#e6edf3",
bgcolor: "#1e1e1e",
"& .MuiOutlinedInput-notchedOutline": { borderColor: "#444" },
"&:hover .MuiOutlinedInput-notchedOutline": { borderColor: "#666" }
}}
>
{TYPE_OPTIONS.map((o) => (
<MenuItem key={o.key} value={o.key}>{o.label}</MenuItem>
))}
</Select>
</FormControl>
<Box sx={{ flex: 1 }} />
{fileName && (
<Button
onClick={() => { setRenameValue(fileName); setRenameOpen(true); }}
sx={{ color: "#58a6ff", textTransform: "none" }}
>
Rename: {fileName}
</Button>
)}
<Button
onClick={saveScript}
sx={{
color: "#58a6ff",
borderColor: "#58a6ff",
textTransform: "none",
border: "1px solid #58a6ff",
backgroundColor: "#1e1e1e",
"&:hover": { backgroundColor: "#1b1b1b" }
}}
>
Save
</Button>
</Box>
{/* Editor input */}
<TextField
multiline
minRows={18}
placeholder={currentPath ? `Editing: ${currentPath}` : "New Script..."}
value={code}
onChange={(e) => setCode(e.target.value)}
sx={{
flex: 0,
mb: 2,
"& .MuiOutlinedInput-root": {
backgroundColor: "#121212",
color: "#e6edf3",
fontFamily: "monospace",
fontSize: "0.9rem",
lineHeight: 1.4,
"& fieldset": { borderColor: "#444" },
"&:hover fieldset": { borderColor: "#666" }
}
}}
/>
{/* Highlighted preview */}
<Box sx={{ flex: 1, minHeight: 120, overflowY: "auto", border: "1px solid #444", borderRadius: 1, p: 1, background: "#121212" }}>
<Typography variant="caption" sx={{ color: "#aaa" }}>Preview (syntax highlighted)</Typography>
<pre style={{ margin: 0 }}>
<code dangerouslySetInnerHTML={{ __html: highlighted }} />
</pre>
</Box>
</Paper>
{/* Dialogs */}
<RenameScriptDialog
open={renameOpen}
value={renameValue}
onChange={setRenameValue}
onCancel={() => setRenameOpen(false)}
onSave={saveRenameFile}
/>
<RenameFolderDialog
open={renameFolderOpen}
value={renameValue}
onChange={setRenameValue}
onCancel={() => setRenameFolderOpen(false)}
onSave={saveRenameFolder}
title={folderDialogMode === "rename" ? "Rename Folder" : "New Folder"}
confirmText={folderDialogMode === "rename" ? "Save" : "Create"}
/>
<NewScriptDialog
open={newScriptOpen}
name={newScriptName}
type={newScriptType}
onChangeName={setNewScriptName}
onChangeType={setNewScriptType}
onCancel={() => setNewScriptOpen(false)}
onCreate={createNewScript}
/>
<ConfirmDeleteDialog
open={deleteOpen}
message="If you delete this, there is no undo button, are you sure you want to proceed?"
onCancel={() => setDeleteOpen(false)}
onConfirm={confirmDelete}
/>
</Box>
);
}

View File

@@ -15,6 +15,7 @@ import json # For reading workflow JSON files
import shutil # For moving workflow files and folders
from typing import List, Dict
import sqlite3
import io
# Borealis Python API Endpoints
from Python_API_Endpoints.ocr_engines import run_ocr_on_base64
@@ -371,6 +372,266 @@ def rename_workflow():
except Exception as e:
return jsonify({"error": str(e)}), 500
# ---------------------------------------------
# Scripts Storage API Endpoints
# ---------------------------------------------
def _scripts_root() -> str:
return os.path.abspath(
os.path.join(os.path.dirname(__file__), "..", "..", "Scripts")
)
def _detect_script_type(filename: str) -> str:
fn = (filename or "").lower()
if fn.endswith(".yml"):
return "ansible"
if fn.endswith(".ps1"):
return "powershell"
if fn.endswith(".bat"):
return "batch"
if fn.endswith(".sh"):
return "bash"
return "unknown"
def _ext_for_type(script_type: str) -> str:
t = (script_type or "").lower()
if t == "ansible":
return ".yml"
if t == "powershell":
return ".ps1"
if t == "batch":
return ".bat"
if t == "bash":
return ".sh"
return ""
@app.route("/api/scripts/list", methods=["GET"])
def list_scripts():
"""Scan <ProjectRoot>/Scripts for known script files and return list + folders."""
scripts_root = _scripts_root()
results: List[Dict] = []
folders: List[str] = []
if not os.path.isdir(scripts_root):
return jsonify({
"root": scripts_root,
"scripts": [],
"folders": []
}), 200
exts = (".yml", ".ps1", ".bat", ".sh")
for root, dirs, files in os.walk(scripts_root):
rel_root = os.path.relpath(root, scripts_root)
if rel_root != ".":
folders.append(rel_root.replace(os.sep, "/"))
for fname in files:
if not fname.lower().endswith(exts):
continue
full_path = os.path.join(root, fname)
rel_path = os.path.relpath(full_path, scripts_root)
parts = rel_path.split(os.sep)
folder_parts = parts[:-1]
breadcrumb_prefix = " > ".join(folder_parts) if folder_parts else ""
display_name = f"{breadcrumb_prefix} > {fname}" if breadcrumb_prefix else fname
try:
mtime = os.path.getmtime(full_path)
except Exception:
mtime = 0.0
results.append({
"name": display_name,
"breadcrumb_prefix": breadcrumb_prefix,
"file_name": fname,
"rel_path": rel_path.replace(os.sep, "/"),
"type": _detect_script_type(fname),
"last_edited": time.strftime("%Y-%m-%dT%H:%M:%S", time.localtime(mtime)),
"last_edited_epoch": mtime
})
results.sort(key=lambda x: x.get("last_edited_epoch", 0.0), reverse=True)
return jsonify({
"root": scripts_root,
"scripts": results,
"folders": folders
})
@app.route("/api/scripts/load", methods=["GET"])
def load_script():
rel_path = request.args.get("path", "")
scripts_root = _scripts_root()
abs_path = os.path.abspath(os.path.join(scripts_root, rel_path))
if not abs_path.startswith(scripts_root) or not os.path.isfile(abs_path):
return jsonify({"error": "Script not found"}), 404
try:
with open(abs_path, "r", encoding="utf-8", errors="replace") as fh:
content = fh.read()
return jsonify({
"file_name": os.path.basename(abs_path),
"rel_path": os.path.relpath(abs_path, scripts_root).replace(os.sep, "/"),
"type": _detect_script_type(abs_path),
"content": content
})
except Exception as e:
return jsonify({"error": str(e)}), 500
@app.route("/api/scripts/save", methods=["POST"])
def save_script():
data = request.get_json(silent=True) or {}
rel_path = (data.get("path") or "").strip()
name = (data.get("name") or "").strip()
content = data.get("content")
script_type = (data.get("type") or "").strip().lower()
if content is None:
return jsonify({"error": "Missing content"}), 400
scripts_root = _scripts_root()
os.makedirs(scripts_root, exist_ok=True)
# Determine target path
if rel_path:
# Ensure extension matches type if provided
base, ext = os.path.splitext(rel_path)
desired_ext = _ext_for_type(script_type) or ext
if desired_ext and not rel_path.lower().endswith(desired_ext):
rel_path = base + desired_ext
abs_path = os.path.abspath(os.path.join(scripts_root, rel_path))
else:
if not name:
return jsonify({"error": "Missing name"}), 400
desired_ext = _ext_for_type(script_type) or os.path.splitext(name)[1] or ".txt"
if not name.lower().endswith(desired_ext):
name = os.path.splitext(name)[0] + desired_ext
abs_path = os.path.abspath(os.path.join(scripts_root, os.path.basename(name)))
if not abs_path.startswith(scripts_root):
return jsonify({"error": "Invalid path"}), 400
os.makedirs(os.path.dirname(abs_path), exist_ok=True)
try:
with open(abs_path, "w", encoding="utf-8", newline="\n") as fh:
fh.write(str(content))
rel_new = os.path.relpath(abs_path, scripts_root).replace(os.sep, "/")
return jsonify({"status": "ok", "rel_path": rel_new})
except Exception as e:
return jsonify({"error": str(e)}), 500
@app.route("/api/scripts/rename_file", methods=["POST"])
def rename_script_file():
data = request.get_json(silent=True) or {}
rel_path = (data.get("path") or "").strip()
new_name = (data.get("new_name") or "").strip()
script_type = (data.get("type") or "").strip().lower()
scripts_root = _scripts_root()
old_abs = os.path.abspath(os.path.join(scripts_root, rel_path))
if not old_abs.startswith(scripts_root) or not os.path.isfile(old_abs):
return jsonify({"error": "File not found"}), 404
if not new_name:
return jsonify({"error": "Invalid new_name"}), 400
desired_ext = _ext_for_type(script_type) or os.path.splitext(new_name)[1]
if desired_ext and not new_name.lower().endswith(desired_ext):
new_name = os.path.splitext(new_name)[0] + desired_ext
new_abs = os.path.join(os.path.dirname(old_abs), os.path.basename(new_name))
try:
os.rename(old_abs, new_abs)
rel_new = os.path.relpath(new_abs, scripts_root).replace(os.sep, "/")
return jsonify({"status": "ok", "rel_path": rel_new})
except Exception as e:
return jsonify({"error": str(e)}), 500
@app.route("/api/scripts/move_file", methods=["POST"])
def move_script_file():
data = request.get_json(silent=True) or {}
rel_path = (data.get("path") or "").strip()
new_rel = (data.get("new_path") or "").strip()
scripts_root = _scripts_root()
old_abs = os.path.abspath(os.path.join(scripts_root, rel_path))
new_abs = os.path.abspath(os.path.join(scripts_root, new_rel))
if not old_abs.startswith(scripts_root) or not os.path.isfile(old_abs):
return jsonify({"error": "File not found"}), 404
if not new_abs.startswith(scripts_root):
return jsonify({"error": "Invalid destination"}), 400
os.makedirs(os.path.dirname(new_abs), exist_ok=True)
try:
shutil.move(old_abs, new_abs)
return jsonify({"status": "ok"})
except Exception as e:
return jsonify({"error": str(e)}), 500
@app.route("/api/scripts/delete_file", methods=["POST"])
def delete_script_file():
data = request.get_json(silent=True) or {}
rel_path = (data.get("path") or "").strip()
scripts_root = _scripts_root()
abs_path = os.path.abspath(os.path.join(scripts_root, rel_path))
if not abs_path.startswith(scripts_root) or not os.path.isfile(abs_path):
return jsonify({"error": "File not found"}), 404
try:
os.remove(abs_path)
return jsonify({"status": "ok"})
except Exception as e:
return jsonify({"error": str(e)}), 500
@app.route("/api/scripts/create_folder", methods=["POST"])
def scripts_create_folder():
data = request.get_json(silent=True) or {}
rel_path = (data.get("path") or "").strip()
scripts_root = _scripts_root()
abs_path = os.path.abspath(os.path.join(scripts_root, rel_path))
if not abs_path.startswith(scripts_root):
return jsonify({"error": "Invalid path"}), 400
try:
os.makedirs(abs_path, exist_ok=True)
return jsonify({"status": "ok"})
except Exception as e:
return jsonify({"error": str(e)}), 500
@app.route("/api/scripts/delete_folder", methods=["POST"])
def scripts_delete_folder():
data = request.get_json(silent=True) or {}
rel_path = (data.get("path") or "").strip()
scripts_root = _scripts_root()
abs_path = os.path.abspath(os.path.join(scripts_root, rel_path))
if not abs_path.startswith(scripts_root) or not os.path.isdir(abs_path):
return jsonify({"error": "Folder not found"}), 404
try:
shutil.rmtree(abs_path)
return jsonify({"status": "ok"})
except Exception as e:
return jsonify({"error": str(e)}), 500
@app.route("/api/scripts/rename_folder", methods=["POST"])
def scripts_rename_folder():
data = request.get_json(silent=True) or {}
rel_path = (data.get("path") or "").strip()
new_name = (data.get("new_name") or "").strip()
scripts_root = _scripts_root()
old_abs = os.path.abspath(os.path.join(scripts_root, rel_path))
if not old_abs.startswith(scripts_root) or not os.path.isdir(old_abs):
return jsonify({"error": "Folder not found"}), 404
if not new_name:
return jsonify({"error": "Invalid new_name"}), 400
new_abs = os.path.join(os.path.dirname(old_abs), new_name)
try:
os.rename(old_abs, new_abs)
return jsonify({"status": "ok"})
except Exception as e:
return jsonify({"error": str(e)}), 500
# ---------------------------------------------
# Borealis Agent API Endpoints
# ---------------------------------------------