$ 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-18
v1.1.12026-06-05
v1.1.02026-06-05
v1.0.02026-06-05