$ cat node-template.py
s
save-file
// Save a workspace file or text into a Drive folder
Output
Storage
#drive#storage#file#save
template.py
1"""save-file — persist a workspace file or text blob into the user's Drive.23Takes EITHER an upstream File-port input OR an inline Text input, saves it as a4Drive item under a folder chosen via the ``drive-item-picker`` widget, then5passes the source file/text straight through so downstream nodes can keep using6it. The upload + drive registration is delegated to the ``gais.drive`` SDK7(which holds the privileged ``USER_TOKEN`` and talks to the www-emblema Drive8API); this node stays sandbox-clean and imports no HTTP client.910Idempotent save: the DriveItem id is set to this workspace node's id11(``context["node_id"]``), so re-running the node UPSERTS the same item instead12of creating a duplicate every run.1314Contract15--------16- Reads one JSON envelope from stdin: {"inputs": {...}, "config": {...},17 "context": {...}, "inputSchema": {...}}.18- All declared schema fields arrive under ``envelope["inputs"]``.19- Exactly one of ``file`` / ``text`` must be provided.20- ``destinationFolder`` is a Drive folder UUID (drive-item-picker widget).21- ``name`` is the file name to store; defaults to the source filename for a22 file input and ``note.txt`` for text.23- Writes {"driveItemId", "name", "file", "text"} to stdout on success: the24 saved item id + stored name, plus the source file/text passed through25 unchanged (the unused one is empty).2627HARD-FAIL on any save error (exit 1). A sink node must never report a false28"saved": validation problems, permission denials, quota, and >50 MB payloads29all surface as a non-zero exit with the underlying message on stderr.30"""3132from __future__ import annotations3334import json35import sys36import traceback37from typing import Any3839from gais import Gais404142INPUT_DIR = "/data/input"43OUTPUT_DIR = "/data/output"444546def _extension_of(filename: str | None) -> str:47 """Return the extension (without the dot) of a filename, or "".4849 os/pathlib are lint-blocked, so split by hand. Strips directory components50 first, then treats the text after the last dot as the extension — but only51 when the dot isn't the leading char (so a hidden file like ".env" or a52 dotless name like "潮人图片 2" reports no extension).53 """54 if not filename:55 return ""56 base = filename.replace("\\", "/").rsplit("/", 1)[-1]57 if "." not in base[1:]:58 return ""59 return base.rsplit(".", 1)[1]606162def _resolve_name(raw_name: Any, source_filename: str | None, is_text: bool) -> str:63 """Pick the stored file name and guarantee it has an extension.6465 explicit name > source filename > default. A widget-supplied name often66 omits the extension (e.g. "潮人图片 2"), which would store the file67 extensionless and break the editor/preview's type detection — so when the68 chosen name has no extension we borrow one: the source file's extension for69 a file input, or ".md" for text.70 """71 if isinstance(raw_name, str) and raw_name.strip():72 name = raw_name.strip()73 elif source_filename:74 name = source_filename75 else:76 name = "note" if is_text else "file"7778 if _extension_of(name):79 return name80 if is_text:81 return f"{name}.md"82 src_ext = _extension_of(source_filename)83 return f"{name}.{src_ext}" if src_ext else name848586def _copy_to_output(src_path: str, name: str) -> None:87 """Stream-copy a staged input file into /data/output so it passes through as88 the File output. os/shutil are lint-blocked, so copy with plain open() in89 1 MiB chunks (never read the whole file into the 512 MB container)."""90 with open(src_path, "rb") as src, open(f"{OUTPUT_DIR}/{name}", "wb") as dst:91 while True:92 chunk = src.read(1024 * 1024)93 if not chunk:94 break95 dst.write(chunk)969798def process(inputs: dict[str, Any], context: dict[str, Any]) -> dict[str, Any]:99 """Validate inputs, save to Drive via gais.drive, pass the source through.100101 Raises ``ValueError`` on bad input (programmer/wiring error); the privileged102 network work is delegated to ``gais.drive.save`` and any SDK exception103 propagates (hard-fail).104 """105 file_name = inputs.get("file")106 text = inputs.get("text")107108 has_file = isinstance(file_name, str) and file_name.strip() != ""109 has_text = isinstance(text, str) and text != ""110111 if has_file and has_text:112 raise ValueError("provide exactly one of 'file' or 'text', not both")113 if not has_file and not has_text:114 raise ValueError("no input: connect a 'file' or provide 'text'")115116 folder_id = inputs.get("destinationFolder")117 if not isinstance(folder_id, str) or not folder_id.strip():118 raise ValueError("'destinationFolder' is required — pick a Drive folder")119 folder_id = folder_id.strip()120121 name = _resolve_name(122 inputs.get("name"),123 source_filename=(file_name if has_file else None),124 is_text=has_text,125 )126127 # Use the workspace node id as the DriveItem id so re-runs upsert the same128 # item instead of creating duplicates. Absent (e.g. a standalone test129 # harness) → None → the backend creates a fresh item.130 item_id = context.get("node_id") or None131132 if has_file:133 # The executor stages File-port inputs at /data/input/<filename>.134 result = Gais.drive.save(135 folder_id=folder_id,136 name=name,137 file=f"{INPUT_DIR}/{file_name}",138 item_id=item_id,139 )140 else:141 result = Gais.drive.save(142 folder_id=folder_id,143 name=name,144 text=text,145 item_id=item_id,146 )147148 md = result.metadata149 output: dict[str, Any] = {150 "driveItemId": md.get("itemId", ""),151 "name": md.get("name", name),152 "file": "",153 "text": "",154 }155 # Pass the source data through unchanged so downstream nodes can keep using156 # it: a File input is re-staged to /data/output; text is echoed.157 if has_file:158 src_name = str(file_name)159 _copy_to_output(f"{INPUT_DIR}/{src_name}", src_name)160 output["file"] = src_name161 else:162 output["text"] = text163 return output164165166def main() -> None:167 try:168 envelope = json.loads(sys.stdin.read() or "{}")169 if not isinstance(envelope, dict):170 envelope = {}171 inputs: dict[str, Any] = envelope.get("inputs", {}) or {}172 context: dict[str, Any] = envelope.get("context", {}) or {}173 output = process(inputs, context)174 json.dump(output, sys.stdout)175 except Exception as e: # hard-fail: a save that didn't happen must be loud176 error_payload = {177 "error": str(e),178 "errorType": type(e).__name__,179 "traceback": traceback.format_exc(),180 }181 print(json.dumps(error_payload), file=sys.stderr)182 sys.exit(1)183184185if __name__ == "__main__":186 main()187$ git log --oneline
v1.1.1
HEAD
2026-06-05v1.1.02026-06-05
v1.0.02026-06-05