$ 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-03

1.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