$ cat node-template.py
D
Drive Folder Reader
// Reads all documents from a Drive folder recursively. Returns document references that can be passed to a Drive Content Reader. Also returns every file with its sub-folder path, so a loop can save results into the same sub-folders elsewhere.
Input
Storage
#drive#storage#folder
template.py
1"""drive-folder-reader — list document DriveItem ids in a Drive folder.23Migrated from the legacy monolithic template to the new multi-file format.4Privileged Drive IO is delegated to the gais.drive SDK (Gais.drive.list);5this node imports no HTTP client. Returns DriveItem ids (NOT Document refIds)6so downstream nodes can validate permissions via the DriveItem path.78`files` adds each file's sub-folder path, so a loop can save its result into9the same sub-folder elsewhere (src/a/b/c/d.pdf -> dest/a/b/c/d.it.pdf).10"""1112from __future__ import annotations1314import json15import re16import sys17import traceback18from typing import Any1920from gais import Gais2122_UUID_RE = re.compile(23 r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$",24 re.IGNORECASE,25)2627# Deepest folder chain walked for one file. Past it, or on a cycle, the28# file's path is unknown.29_MAX_DEPTH = 100303132def _normalize_drive_item_id(value: Any) -> str:33 """Accept a bare UUID or a mention://drive_{file,folder}/<uuid> URI."""34 if not isinstance(value, str):35 raise ValueError(f"Drive item id must be a string, got {type(value).__name__}")36 s = value.strip()37 for prefix in ("mention://drive_file/", "mention://drive_folder/"):38 if s.startswith(prefix):39 s = s[len(prefix):]40 break41 if not _UUID_RE.match(s):42 raise ValueError(f"Drive item id is not a valid UUID: {value!r}")43 return s444546def _id_key(value: Any) -> str | None:47 """An id for comparison: UUIDs compare case-insensitively."""48 return value.lower() if isinstance(value, str) and value else None495051def _segment(name: Any) -> str | None:52 """One folder name as one path level, or None when it has no usable name.5354 A "/" inside the name becomes "-", so the path splits back into the right55 levels. So does a "\\": the Drive ensure-path route refuses both.56 """57 if not isinstance(name, str) or not name.strip():58 return None59 return name.replace("/", "-").replace("\\", "-")606162def _folder_path(63 parent_id: Any,64 root_key: str,65 folders: dict[str, dict[str, Any]],66 cache: dict[str, str | None],67) -> str | None:68 """Folder names from below the picked folder down to `parent_id`.6970 Walks parentId up through `folders` until it reaches the picked folder,71 whose own name is not included. Returns "a/b/c", "" for a file directly in72 the picked folder, or None when the chain cannot be resolved: a parent73 missing from `folders` (e.g. not readable), a folder without a name, a74 cycle, or a chain deeper than _MAX_DEPTH.75 """76 start = _id_key(parent_id)77 if start is not None and start in cache:78 return cache[start]7980 parts: list[str] = []81 seen: set[str] = set()82 current = start83 path: str | None = None84 while True:85 if current == root_key:86 path = "/".join(reversed(parts))87 break88 if current is None or current in seen or len(parts) >= _MAX_DEPTH:89 break90 seen.add(current)91 folder = folders.get(current)92 segment = _segment(folder.get("name")) if folder else None93 if segment is None:94 break95 parts.append(segment)96 current = _id_key(folder.get("parentId"))9798 if start is not None:99 cache[start] = path100 return path101102103def _sort_key(entry: dict[str, Any]) -> tuple:104 """Path level by level, then name, ignoring case; id breaks ties."""105 path = entry["path"]106 levels = tuple(part.casefold() for part in path.split("/")) if path else ()107 return (levels, entry["name"].casefold(), entry["name"], entry["driveItemId"])108109110def _build_files(111 file_items: list[dict[str, Any]],112 folder_items: list[dict[str, Any]],113 folder_id: str,114) -> list[dict[str, Any]]:115 """Every file with its sub-folder path, sorted by path then name."""116 root_key = folder_id.lower()117 # `path descendantOf` may return the picked folder itself; skip it.118 folders: dict[str, dict[str, Any]] = {}119 for folder in folder_items:120 key = _id_key(folder.get("id"))121 if key is not None and key != root_key:122 folders[key] = folder123124 cache: dict[str, str | None] = {}125 files: list[dict[str, Any]] = []126 for item in file_items:127 if not item.get("id"):128 continue129 name = item.get("name")130 entry: dict[str, Any] = {131 "driveItemId": item["id"],132 "name": name if isinstance(name, str) else "",133 }134 path = _folder_path(item.get("parentId"), root_key, folders, cache)135 if path is None:136 # Never drop the file: it goes at the top, flagged.137 entry["path"] = ""138 entry["pathUnknown"] = True139 else:140 entry["path"] = path141 files.append(entry)142143 files.sort(key=_sort_key)144 return files145146147def process(inputs: dict[str, Any]) -> dict[str, Any]:148 """List every descendant file/document of the folder via gais.drive.list.149150 `driveItemIds` keeps the files query's own order. `files` adds each151 file's sub-folder path, from a second query for the descendant folders.152 """153 folder_id = inputs.get("driveItemId")154 if not folder_id:155 raise ValueError("driveItemId input is required")156157 folder_id = _normalize_drive_item_id(folder_id)158159 # Recursive query: every descendant that is a file/document (refId != null),160 # NOT subfolders. The Drive API applies the user's permissions automatically161 # (gais.drive carries the user token).162 result = Gais.drive.list(163 query=f"path descendantOf '{folder_id}' and refId!=null",164 page_size=1000,165 )166167 # Return DriveItem ids (NOT Document refIds) so downstream nodes can168 # validate permissions via the DriveItem path.169 file_items = result.data.get("items", [])170 drive_item_ids = [item["id"] for item in file_items if item.get("id")]171172 # The descendant folders (refId = null), to turn each file's parentId173 # into a folder path. A failure propagates, like the files query.174 folder_result = Gais.drive.list(175 query=f"path descendantOf '{folder_id}' and refId=null",176 page_size=1000,177 )178 folder_items = folder_result.data.get("items", [])179180 return {181 "driveItemIds": drive_item_ids,182 "files": _build_files(file_items, folder_items, folder_id),183 }184185186def main() -> None:187 try:188 envelope = json.loads(sys.stdin.read() or "{}")189 if not isinstance(envelope, dict):190 envelope = {}191 inputs = envelope.get("inputs", {}) or {}192 json.dump(process(inputs), sys.stdout)193 except Exception as e:194 print(195 json.dumps({196 "error": str(e),197 "errorType": type(e).__name__,198 "traceback": traceback.format_exc(),199 }),200 file=sys.stderr,201 )202 sys.exit(1)203204205if __name__ == "__main__":206 main()207$ git log --oneline
v1.4.0
HEAD
2026-11-031.4.0: a files output, every file under the folder with its sub-folder path ({driveItemId, name, path}); driveItemIds unchanged.
v1.3.12026-09-28
Description now points to Drive Content Reader (renamed from Drive Document Reader)
v1.3.02026-08-18