$ 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 node passes its node id (``context["node_id"]``) as the11save KEY (under the ``item_id`` kwarg, which every executor SDK accepts). The12gais SDK scopes the key to the executing workspace (the DriveItem13id is a UUIDv5 of the workspace id + key), so re-running the node in one14workspace UPSERTS the same DriveItem instead of creating a duplicate, while a15workspace created from the same template, a macro call, or a template test16workspace (all of which share node ids) each get their own item. With no node17id (a standalone harness) no key is sent and the backend creates a fresh item.1819Sub-folder: ``subPath`` (e.g. ``reports/2026``) names a folder path under20``destinationFolder``. ``gais.drive.create_folder`` creates the missing levels21and reuses existing ones, and the file is saved into the last level.2223One file per name: with ``oneFilePerName`` off (the default) the key is the24bare node id, so the node keeps ONE Drive file and every run replaces it. On,25the key is ``<node id>|<sub-path>|<name>``, so each file name in each26sub-folder gets its own Drive file: one Save File node in a loop keeps every27file it saves, and a re-run replaces each one in place.2829Contract30--------31- Reads one JSON envelope from stdin: {"inputs": {...}, "config": {...},32 "context": {...}, "inputSchema": {...}}.33- All declared schema fields arrive under ``envelope["inputs"]``.34- Exactly one of ``file`` / ``text`` must be provided. ``text`` may also arrive35 as an array (e.g. from Make Array) — the items are joined into one document.36- ``destinationFolder`` is a Drive folder UUID (drive-item-picker widget).37- ``subPath`` (optional) is a folder path under ``destinationFolder``, parts38 separated by "/". Empty saves straight into ``destinationFolder``.39- ``oneFilePerName`` (optional, default off) picks the save key (see above).40- ``name`` is the file name to store; defaults to the source filename for a41 file input and ``note.txt`` for text.42- Writes {"driveItemId", "name", "file", "text"} to stdout on success: the43 saved item id + stored name, plus the source file/text passed through44 unchanged (the unused one is empty).4546HARD-FAIL on any save error (exit 1). A sink node must never report a false47"saved": validation problems, permission denials, quota, and >50 MB payloads48all surface as a non-zero exit with the underlying message on stderr.49"""5051from __future__ import annotations5253import json54import sys55import traceback56from typing import Any5758from gais import Gais596061INPUT_DIR = "/data/input"62OUTPUT_DIR = "/data/output"636465def _extension_of(filename: str | None) -> str:66 """Return the extension (without the dot) of a filename, or "".6768 os/pathlib are lint-blocked, so split by hand. Strips directory components69 first, then treats the text after the last dot as the extension — but only70 when the dot isn't the leading char (so a hidden file like ".env" or a71 dotless name like "潮人图片 2" reports no extension).72 """73 if not filename:74 return ""75 base = filename.replace("\\", "/").rsplit("/", 1)[-1]76 if "." not in base[1:]:77 return ""78 return base.rsplit(".", 1)[1]798081def _resolve_name(raw_name: Any, source_filename: str | None, is_text: bool) -> str:82 """Pick the stored file name and guarantee it has an extension.8384 explicit name > source filename > default. A widget-supplied name often85 omits the extension (e.g. "潮人图片 2"), which would store the file86 extensionless and break the editor/preview's type detection — so when the87 chosen name has no extension we borrow one: the source file's extension for88 a file input, or ".md" for text.89 """90 if isinstance(raw_name, str) and raw_name.strip():91 name = raw_name.strip()92 elif source_filename:93 name = source_filename94 else:95 name = "note" if is_text else "file"9697 if _extension_of(name):98 return name99 if is_text:100 return f"{name}.md"101 src_ext = _extension_of(source_filename)102 return f"{name}.{src_ext}" if src_ext else name103104105def _copy_to_output(src_path: str, name: str) -> None:106 """Stream-copy a staged input file into /data/output so it passes through as107 the File output. os/shutil are lint-blocked, so copy with plain open() in108 1 MiB chunks (never read the whole file into the 512 MB container)."""109 with open(src_path, "rb") as src, open(f"{OUTPUT_DIR}/{name}", "wb") as dst:110 while True:111 chunk = src.read(1024 * 1024)112 if not chunk:113 break114 dst.write(chunk)115116117def _coerce_text(value: Any) -> Any:118 """Normalize an array fed into the Text port into one text document.119120 A Text input wired from an array source (e.g. the Make Array node) arrives121 as a Python ``list`` at runtime — the executor passes non-file arrays122 through verbatim, so without this the ``isinstance(text, str)`` guard below123 would reject the whole connection as "no input". Join the items into a124 single blob so an array of texts saves as one file: strings pass through,125 other items (numbers, objects) are JSON-encoded, ``None``/empty items are126 dropped, and items are separated by a blank line (the house default, same127 as the Text Merge node). Non-list values are returned unchanged.128 """129 if not isinstance(value, list):130 return value131 parts: list[str] = []132 for item in value:133 if item is None or item == "":134 continue135 parts.append(item if isinstance(item, str) else json.dumps(item, ensure_ascii=False))136 return "\n\n".join(parts)137138139def _flag(value: Any, *, default: bool = False) -> bool:140 """A boolean input; missing or empty (an edge with no value) is ``default``."""141 if value is None or (isinstance(value, str) and not value.strip()):142 return default143 if isinstance(value, str):144 return value.strip().lower() in ("true", "1", "yes", "on")145 if isinstance(value, (bool, int)):146 return bool(value)147 return False148149150# The Drive refuses a folder name longer than 255 characters. 20 levels is far151# deeper than any real folder tree.152_MAX_FOLDER_NAME = 255153_MAX_SUB_FOLDER_LEVELS = 20154# The Drive's ensure-path route takes at most 6 folder names per call, so a155# deeper path is made in steps of 6, each under the last level of the step156# before.157_ENSURE_PATH_STEP = 6158159160def _sub_path_parts(value: Any) -> list[str]:161 """Split the Sub-folder input into folder names, top level first.162163 Parts are separated by "/". Each part is trimmed and empty parts are164 dropped, so " a / /b/ " gives ["a", "b"]. None or an empty path gives []:165 no sub-folder. The Drive reuses a folder only when the name matches166 exactly, so trimming is what makes "a " and "a" the same folder.167168 Raises ValueError on input that is not text, on a "." or ".." part, on a169 backslash or a control character, on a part longer than 255 characters,170 and on more than 20 levels. The caller checks this before any Drive call,171 so a bad path creates no folder.172 """173 if value is None:174 return []175 if not isinstance(value, str):176 raise ValueError(177 "'subPath' must be a folder path like reports/2026, "178 f"not {type(value).__name__}"179 )180 parts = [part.strip() for part in value.split("/")]181 parts = [part for part in parts if part]182 if len(parts) > _MAX_SUB_FOLDER_LEVELS:183 raise ValueError(184 f"'subPath' has {len(parts)} levels; the limit is {_MAX_SUB_FOLDER_LEVELS}"185 )186 for part in parts:187 if part in (".", ".."):188 raise ValueError(f"'subPath' cannot contain {part!r}; name each folder")189 if "\\" in part:190 raise ValueError(191 f"'subPath' folder {part!r} contains a backslash; separate folders with /"192 )193 if any(ord(ch) < 0x20 or 0x7F <= ord(ch) <= 0x9F for ch in part):194 raise ValueError(f"'subPath' folder {part!r} contains a control character")195 if len(part) > _MAX_FOLDER_NAME:196 raise ValueError(197 f"'subPath' folder {part[:40]!r}... is longer than "198 f"{_MAX_FOLDER_NAME} characters"199 )200 return parts201202203def _ensure_sub_folder(root_id: str, parts: list[str]) -> str:204 """Create the missing levels of ``parts`` under ``root_id``; return the last one's id.205206 ``gais.drive.create_folder`` reuses a level that already exists, so every207 run (and every file of a loop) lands in the same folder. A path deeper208 than the route's 6-name limit is made in steps.209 """210 folder_id = root_id211 for start in range(0, len(parts), _ENSURE_PATH_STEP):212 step = parts[start:start + _ENSURE_PATH_STEP]213 result = Gais.drive.create_folder(step, root_id=folder_id)214 leaf = (result.metadata or {}).get("folderId")215 if not isinstance(leaf, str) or not leaf:216 done = "/".join(parts[:start + len(step)])217 raise RuntimeError(f"Drive returned no folder id for sub-folder {done!r}")218 folder_id = leaf219 return folder_id220221222def process(inputs: dict[str, Any], context: dict[str, Any]) -> dict[str, Any]:223 """Validate inputs, make the sub-folder if one is named, save to Drive via224 gais.drive, pass the source through.225226 Raises ``ValueError`` on bad input (programmer/wiring error); the privileged227 network work is delegated to ``gais.drive.create_folder`` and228 ``gais.drive.save``, and any SDK exception propagates (hard-fail).229 """230 file_name = inputs.get("file")231 # A single-item sink can't save many files under one DriveItem. A File port232 # wired from an array source (Make Array passing files through) arrives as a233 # list — fail loud rather than silently saving only the first.234 if isinstance(file_name, list):235 raise ValueError(236 f"save-file saves a single file but received a list of {len(file_name)} "237 "files; connect one file, or save each separately"238 )239240 # The Text port accepts an array source (e.g. Make Array): join the items241 # into one document so the array saves as a single text file.242 text = _coerce_text(inputs.get("text"))243244 has_file = isinstance(file_name, str) and file_name.strip() != ""245 has_text = isinstance(text, str) and text != ""246247 if has_file and has_text:248 raise ValueError("provide exactly one of 'file' or 'text', not both")249 if not has_file and not has_text:250 raise ValueError("no input: connect a 'file' or provide 'text'")251252 folder_id = inputs.get("destinationFolder")253 if not isinstance(folder_id, str) or not folder_id.strip():254 raise ValueError("'destinationFolder' is required — pick a Drive folder")255 folder_id = folder_id.strip()256257 # Checked here, before any Drive call: a bad path must create no folder.258 sub_path = _sub_path_parts(inputs.get("subPath"))259 one_file_per_name = _flag(inputs.get("oneFilePerName"))260261 name = _resolve_name(262 inputs.get("name"),263 source_filename=(file_name if has_file else None),264 is_text=has_text,265 )266267 # The node id is the save KEY, not the DriveItem id: a node id is unique268 # only inside one canvas, and the gais SDK scopes the key to the executing269 # workspace (see the module docstring). It goes in under `item_id`, the270 # spelling every executor SDK accepts: an image that predates `key` would271 # reject the new kwarg with TypeError, and templates reach a box272 # independently of its executor rebuild. Absent (e.g. a standalone test273 # harness) → None → the backend creates a fresh item.274 #275 # With oneFilePerName off the key MUST stay the bare node id, sub-folder or276 # not: every file saved so far is keyed that way, and any other key makes277 # the next run create a second file. On, the sub-folder and the stored name278 # join the key, so each name in each sub-folder is its own file. A JSON list,279 # not a joined string: no folder or file name can make two keys equal.280 node_id = context.get("node_id") or None281 if one_file_per_name and node_id:282 key = json.dumps([node_id, list(sub_path), name], ensure_ascii=False, separators=(",", ":"))283 else:284 key = node_id285286 if sub_path:287 folder_id = _ensure_sub_folder(folder_id, sub_path)288289 if has_file:290 # The executor stages File-port inputs at /data/input/<filename>.291 result = Gais.drive.save(292 folder_id=folder_id,293 name=name,294 file=f"{INPUT_DIR}/{file_name}",295 item_id=key,296 )297 else:298 result = Gais.drive.save(299 folder_id=folder_id,300 name=name,301 text=text,302 item_id=key,303 )304305 md = result.metadata306 output: dict[str, Any] = {307 "driveItemId": md.get("itemId", ""),308 "name": md.get("name", name),309 "file": "",310 "text": "",311 }312 # Pass the source data through unchanged so downstream nodes can keep using313 # it: a File input is re-staged to /data/output; text is echoed.314 if has_file:315 src_name = str(file_name)316 _copy_to_output(f"{INPUT_DIR}/{src_name}", src_name)317 output["file"] = src_name318 else:319 output["text"] = text320 return output321322323def main() -> None:324 try:325 envelope = json.loads(sys.stdin.read() or "{}")326 if not isinstance(envelope, dict):327 envelope = {}328 inputs: dict[str, Any] = envelope.get("inputs", {}) or {}329 context: dict[str, Any] = envelope.get("context", {}) or {}330 output = process(inputs, context)331 json.dump(output, sys.stdout)332 except Exception as e: # hard-fail: a save that didn't happen must be loud333 error_payload = {334 "error": str(e),335 "errorType": type(e).__name__,336 "traceback": traceback.format_exc(),337 }338 print(json.dumps(error_payload), file=sys.stderr)339 sys.exit(1)340341342if __name__ == "__main__":343 main()344$ git log --oneline
v1.4.0
HEAD
2026-11-031.4.0: subPath saves under a sub-folder of the destination (created when missing); oneFilePerName gives each file name in each sub-folder its own Drive file, so one node can save every file of a loop. Off by default: unchanged behaviour.
v1.3.12026-10-26
Height-only patch: measured node height 545 so layout tools space nodes without overlap.
v1.3.02026-10-23
Each workspace now saves to its own Drive file: the save id is scoped to the workspace, so copies of one template (and macro calls) no longer share and overwrite one file. The first run after the update writes a new file.
v1.2.02026-08-18