Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -1033,7 +1033,7 @@ The required scopes (`chat.spaces.readonly`, `chat.messages.readonly`, `chat.mes
| <sub>`get_script_project`</sub> | <sub>Core</sub> | <sub>Get complete project with all files</sub> |
| <sub>`get_script_content`</sub> | <sub>Core</sub> | <sub>Retrieve specific file content</sub> |
| <sub>`create_script_project`</sub> | <sub>Core</sub> | <sub>Create new standalone or bound project</sub> |
| <sub>`update_script_content`</sub> | <sub>Core</sub> | <sub>Update or create script files</sub> |
| <sub>`update_script_content`</sub> | <sub>Core</sub> | <sub>Merge or replace script project files</sub> |
| <sub>`run_script_function`</sub> | <sub>Core</sub> | <sub>Execute function with parameters</sub> |
| <sub>`list_deployments`</sub> | <sub>Extended</sub> | <sub>List all project deployments</sub> |
| <sub>`manage_deployment`</sub> | <sub>Extended</sub> | <sub>Create, update, or delete script deployments</sub> |
Expand Down
2 changes: 1 addition & 1 deletion README_NEW.md
Original file line number Diff line number Diff line change
Expand Up @@ -174,7 +174,7 @@ export OAUTHLIB_INSECURE_TRANSPORT=1 # Development only
| `get_script_project` | Core | Get complete project with all files |
| `get_script_content` | Core | Retrieve specific file content |
| `create_script_project` | Core | Create new standalone or bound project |
| `update_script_content` | Core | Update or create script files |
| `update_script_content` | Core | Merge or replace script project files |
| `run_script_function` | Core | Execute function with parameters |
| `list_deployments` | Extended | List all project deployments |
| `manage_deployment` | Extended | Create, update, or delete script deployments |
Expand Down
6 changes: 4 additions & 2 deletions gappsscript/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -186,7 +186,7 @@ Essential operations for reading, writing, and executing scripts:
- `get_script_project`: Get full project with all files
- `get_script_content`: Get specific file content
- `create_script_project`: Create new project
- `update_script_content`: Modify project files
- `update_script_content`: Merge or replace project files (`merge=true` by default)
- `run_script_function`: Execute functions
- `generate_trigger_code`: Generate trigger setup code

Expand Down Expand Up @@ -271,7 +271,9 @@ Files:
The AI will:
1. Read current code
2. Generate improved version
3. Call `update_script_content` with new files
3. Call `update_script_content` with the changed files (`merge=true` merges by file name)

To delete files or replace the entire project, pass `merge=false` with the complete desired file set.

### Run Script Function

Expand Down
70 changes: 63 additions & 7 deletions gappsscript/apps_script_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,35 @@
logger = logging.getLogger(__name__)


def _normalize_script_file(file: Dict[str, Any]) -> Dict[str, str]:
"""Return the Script API file fields used for updateContent requests."""
return {
"name": file.get("name", ""),
"type": file.get("type", ""),
"source": file.get("source", ""),
}


def _merge_script_files(
existing_files: List[Dict[str, Any]],
updated_files: List[Dict[str, Any]],
) -> List[Dict[str, str]]:
"""Overlay updated files onto the current project by file name."""
merged = {
file.get("name"): _normalize_script_file(file)
for file in existing_files
if file.get("name")
}

for file in updated_files:
name = file.get("name")
if not name:
continue
merged[name] = _normalize_script_file(file)

return list(merged.values())


# Internal implementation functions for testing
async def _list_script_projects_impl(
service: Any,
Expand Down Expand Up @@ -320,26 +349,45 @@ async def _update_script_content_impl(
user_google_email: str,
script_id: str,
files: List[Dict[str, str]],
merge: bool = True,
) -> str:
"""Internal implementation for update_script_content."""
logger.info(
f"[update_script_content] Email: {user_google_email}, ID: {script_id}, Files: {len(files)}"
f"[update_script_content] Email: {user_google_email}, ID: {script_id}, "
f"Files: {len(files)}, merge: {merge}"
)

request_body = {"files": files}
files_to_push = [_normalize_script_file(file) for file in files]

if merge:
current_content = await asyncio.to_thread(
service.projects().getContent(scriptId=script_id).execute
)
files_to_push = _merge_script_files(
current_content.get("files", []), files_to_push
)

request_body = {"files": files_to_push}

updated_content = await asyncio.to_thread(
service.projects().updateContent(scriptId=script_id, body=request_body).execute
)

output = [f"Updated script project: {script_id}", "", "Modified files:"]
mode = "merged into project" if merge else "replaced entire project"
output = [
f"Updated script project: {script_id} ({mode})",
"",
"Files in project after update:",
]

for file in updated_content.get("files", []):
file_name = file.get("name", "Untitled")
file_type = file.get("type", "Unknown")
output.append(f"- {file_name} ({file_type})")

logger.info(f"[update_script_content] Updated {len(files)} files in {script_id}")
logger.info(
f"[update_script_content] Updated {len(files_to_push)} files in {script_id}"
)
return "\n".join(output)


Expand All @@ -359,21 +407,29 @@ async def update_script_content(
user_google_email: str,
script_id: str,
files: List[Dict[str, str]],
merge: bool = True,
) -> str:
"""
Updates or creates files in a script project.
Update or create files in a script project.

By default this merges the supplied files into the existing project by file
name, leaving other files untouched. Set merge=False to replace the entire
project: any existing file omitted from `files` is permanently deleted.

Args:
service: Injected Google API service client
user_google_email: User's email address
script_id: The script project ID
files: List of file objects with name, type, and source
files: File objects with name, type, and source to create or update
merge: When True (default), overlay these files onto the current
project. When False, replace the full project file set; omitted
files are deleted.

Returns:
str: Formatted string confirming update with file list
"""
return await _update_script_content_impl(
service, user_google_email, script_id, files
service, user_google_email, script_id, files, merge
)


Expand Down
5 changes: 3 additions & 2 deletions skills/managing-google-workspace/references/apps-script.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,13 +63,14 @@ Retrieves content of a specific file within a project.
| file_name | string | yes | | |

### update_script_content
Updates or creates files in a script project.
Merge or replace files in a script project. Defaults to merging supplied files by name into the existing project; set `merge=false` to replace the full project (omitted files are deleted).

| Parameter | Type | Required | Default | Notes |
|-----------|------|----------|---------|-------|
| user_google_email | string | yes | | |
| script_id | string | yes | | |
| files | array | yes | | List of objects with `name`, `type`, and `source` |
| files | array | yes | | Objects with `name`, `type`, and `source` |
| merge | boolean | no | true | `true` overlays updates; `false` replaces the entire project |

---

Expand Down
68 changes: 67 additions & 1 deletion tests/gappsscript/test_apps_script_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
_get_script_project_impl,
_create_script_project_impl,
_update_script_content_impl,
_merge_script_files,
_run_script_function_impl,
_create_deployment_impl,
_list_deployments_impl,
Expand Down Expand Up @@ -140,7 +141,7 @@ async def test_create_script_project():

@pytest.mark.asyncio
async def test_update_script_content():
"""Test updating script project files"""
"""Test updating script project files with merge disabled."""
mock_service = Mock()
files_to_update = [
{"name": "Code", "type": "SERVER_JS", "source": "function main() {}"}
Expand All @@ -154,10 +155,75 @@ async def test_update_script_content():
user_google_email="test@example.com",
script_id="test123",
files=files_to_update,
merge=False,
)

assert "Updated script project: test123" in result
assert "replaced entire project" in result
assert "Code" in result
mock_service.projects().getContent.assert_not_called()


def test_merge_script_files_overlays_updates_and_preserves_existing():
existing = [
{"name": "Code", "type": "SERVER_JS", "source": "old code"},
{"name": "appsscript", "type": "JSON", "source": "{}"},
]
updates = [{"name": "Code", "type": "SERVER_JS", "source": "new code"}]

merged = _merge_script_files(existing, updates)

assert len(merged) == 2
by_name = {file["name"]: file for file in merged}
assert by_name["Code"]["source"] == "new code"
assert by_name["appsscript"]["source"] == "{}"


def test_merge_script_files_adds_new_file():
existing = [{"name": "Code", "type": "SERVER_JS", "source": "code"}]
updates = [{"name": "Utils", "type": "SERVER_JS", "source": "function util() {}"}]

merged = _merge_script_files(existing, updates)

assert len(merged) == 2
by_name = {file["name"]: file for file in merged}
assert by_name["Utils"]["source"] == "function util() {}"
assert by_name["Code"]["source"] == "code"


@pytest.mark.asyncio
async def test_update_script_content_merge_fetches_existing_files():
"""Test merge=True overlays updates onto the current project."""
mock_service = Mock()
existing_files = [
{"name": "Code", "type": "SERVER_JS", "source": "old code"},
{"name": "appsscript", "type": "JSON", "source": "{}"},
]
files_to_update = [
{"name": "Code", "type": "SERVER_JS", "source": "new code"}
]
merged_files = _merge_script_files(existing_files, files_to_update)

mock_service.projects().getContent().execute.return_value = {
"files": existing_files
}
mock_service.projects().updateContent().execute.return_value = {
"files": merged_files
}

result = await _update_script_content_impl(
service=mock_service,
user_google_email="test@example.com",
script_id="test123",
files=files_to_update,
merge=True,
)

update_body = mock_service.projects().updateContent.call_args.kwargs["body"]
assert update_body == {"files": merged_files}
assert "merged into project" in result
assert "Code" in result
assert "appsscript" in result


@pytest.mark.asyncio
Expand Down
Loading