$ 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. ``text`` may also arrive20 as an array (e.g. from Make Array) — the items are joined into one document.21- ``destinationFolder`` is a Drive folder UUID (drive-item-picker widget).22- ``name`` is the file name to store; defaults to the source filename for a23 file input and ``note.txt`` for text.24- Writes {"driveItemId", "name", "file", "text"} to stdout on success: the25 saved item id + stored name, plus the source file/text passed through26 unchanged (the unused one is empty).2728HARD-FAIL on any save error (exit 1). A sink node must never report a false29"saved": validation problems, permission denials, quota, and >50 MB payloads30all surface as a non-zero exit with the underlying message on stderr.31"""3233from __future__ import annotations3435import json36import sys37import traceback38from typing import Any3940from gais import Gais414243INPUT_DIR = "/data/input"44OUTPUT_DIR = "/data/output"454647def _extension_of(filename: str | None) -> str:48 """Return the extension (without the dot) of a filename, or "".4950 os/pathlib are lint-blocked, so split by hand. Strips directory components51 first, then treats the text after the last dot as the extension — but only52 when the dot isn't the leading char (so a hidden file like ".env" or a53 dotless name like "潮人图片 2" reports no extension).54 """55 if not filename:56 return ""57 base = filename.replace("\\", "/").rsplit("/", 1)[-1]58 if "." not in base[1:]:59 return ""60 return base.rsplit(".", 1)[1]616263def _resolve_name(raw_name: Any, source_filename: str | None, is_text: bool) -> str:64 """Pick the stored file name and guarantee it has an extension.6566 explicit name > source filename > default. A widget-supplied name often67 omits the extension (e.g. "潮人图片 2"), which would store the file68 extensionless and break the editor/preview's type detection — so when the69 chosen name has no extension we borrow one: the source file's extension for70 a file input, or ".md" for text.71 """72 if isinstance(raw_name, str) and raw_name.strip():73 name = raw_name.strip()74 elif source_filename:75 name = source_filename76 else:77 name = "note" if is_text else "file"7879 if _extension_of(name):80 return name81 if is_text:82 return f"{name}.md"83 src_ext = _extension_of(source_filename)84 return f"{name}.{src_ext}" if src_ext else name858687def _copy_to_output(src_path: str, name: str) -> None:88 """Stream-copy a staged input file into /data/output so it passes through as89 the File output. os/shutil are lint-blocked, so copy with plain open() in90 1 MiB chunks (never read the whole file into the 512 MB container)."""91 with open(src_path, "rb") as src, open(f"{OUTPUT_DIR}/{name}", "wb") as dst:92 while True:93 chunk = src.read(1024 * 1024)94 if not chunk:95 break96 dst.write(chunk)979899def _coerce_text(value: Any) -> Any:100 """Normalize an array fed into the Text port into one text document.101102 A Text input wired from an array source (e.g. the Make Array node) arrives103 as a Python ``list`` at runtime — the executor passes non-file arrays104 through verbatim, so without this the ``isinstance(text, str)`` guard below105 would reject the whole connection as "no input". Join the items into a106 single blob so an array of texts saves as one file: strings pass through,107 other items (numbers, objects) are JSON-encoded, ``None``/empty items are108 dropped, and items are separated by a blank line (the house default, same109 as the Text Merge node). Non-list values are returned unchanged.110 """111 if not isinstance(value, list):112 return value113 parts: list[str] = []114 for item in value:115 if item is None or item == "":116 continue117 parts.append(item if isinstance(item, str) else json.dumps(item, ensure_ascii=False))118 return "\n\n".join(parts)119120121def process(inputs: dict[str, Any], context: dict[str, Any]) -> dict[str, Any]:122 """Validate inputs, save to Drive via gais.drive, pass the source through.123124 Raises ``ValueError`` on bad input (programmer/wiring error); the privileged125 network work is delegated to ``gais.drive.save`` and any SDK exception126 propagates (hard-fail).127 """128 file_name = inputs.get("file")129 # A single-item sink can't save many files under one DriveItem. A File port130 # wired from an array source (Make Array passing files through) arrives as a131 # list — fail loud rather than silently saving only the first.132 if isinstance(file_name, list):133 raise ValueError(134 f"save-file saves a single file but received a list of {len(file_name)} "135 "files; connect one file, or save each separately"136 )137138 # The Text port accepts an array source (e.g. Make Array): join the items139 # into one document so the array saves as a single text file.140 text = _coerce_text(inputs.get("text"))141142 has_file = isinstance(file_name, str) and file_name.strip() != ""143 has_text = isinstance(text, str) and text != ""144145 if has_file and has_text:146 raise ValueError("provide exactly one of 'file' or 'text', not both")147 if not has_file and not has_text:148 raise ValueError("no input: connect a 'file' or provide 'text'")149150 folder_id = inputs.get("destinationFolder")151 if not isinstance(folder_id, str) or not folder_id.strip():152 raise ValueError("'destinationFolder' is required — pick a Drive folder")153 folder_id = folder_id.strip()154155 name = _resolve_name(156 inputs.get("name"),157 source_filename=(file_name if has_file else None),158 is_text=has_text,159 )160161 # Use the workspace node id as the DriveItem id so re-runs upsert the same162 # item instead of creating duplicates. Absent (e.g. a standalone test163 # harness) → None → the backend creates a fresh item.164 item_id = context.get("node_id") or None165166 if has_file:167 # The executor stages File-port inputs at /data/input/<filename>.168 result = Gais.drive.save(169 folder_id=folder_id,170 name=name,171 file=f"{INPUT_DIR}/{file_name}",172 item_id=item_id,173 )174 else:175 result = Gais.drive.save(176 folder_id=folder_id,177 name=name,178 text=text,179 item_id=item_id,180 )181182 md = result.metadata183 output: dict[str, Any] = {184 "driveItemId": md.get("itemId", ""),185 "name": md.get("name", name),186 "file": "",187 "text": "",188 }189 # Pass the source data through unchanged so downstream nodes can keep using190 # it: a File input is re-staged to /data/output; text is echoed.191 if has_file:192 src_name = str(file_name)193 _copy_to_output(f"{INPUT_DIR}/{src_name}", src_name)194 output["file"] = src_name195 else:196 output["text"] = text197 return output198199200def main() -> None:201 try:202 envelope = json.loads(sys.stdin.read() or "{}")203 if not isinstance(envelope, dict):204 envelope = {}205 inputs: dict[str, Any] = envelope.get("inputs", {}) or {}206 context: dict[str, Any] = envelope.get("context", {}) or {}207 output = process(inputs, context)208 json.dump(output, sys.stdout)209 except Exception as e: # hard-fail: a save that didn't happen must be loud210 error_payload = {211 "error": str(e),212 "errorType": type(e).__name__,213 "traceback": traceback.format_exc(),214 }215 print(json.dumps(error_payload), file=sys.stderr)216 sys.exit(1)217218219if __name__ == "__main__":220 main()$ git log --oneline
v1.2.0
HEAD
2026-08-18v1.1.12026-06-05
v1.1.02026-06-05
v1.0.02026-06-05