Skip to content

Instantly share code, notes, and snippets.

@jkeam
Forked from dmc5179/README.md
Created August 24, 2026 15:10
Show Gist options
  • Select an option

  • Save jkeam/9343ebb733d6ec2d8307bcfe6d548074 to your computer and use it in GitHub Desktop.

Select an option

Save jkeam/9343ebb733d6ec2d8307bcfe6d548074 to your computer and use it in GitHub Desktop.
Gemini Notes to Notebook

notes-to-notebook

Find Gemini meeting notes in Google Drive, move them to a specified folder, and add them as sources to a NotebookLM notebook.

When Gemini takes notes during a Google Meet, it creates a Google Doc named after the meeting subject (e.g. "Team-A Weekly Team Sync - 2025-08-21"). This script finds those docs, moves them out of the default Google Meet folder into a folder you choose, and adds each one as a source in NotebookLM.

Prerequisites

  • gws CLI with a configured profile (default: redhat)
  • nlm CLI authenticated via nlm login
  • jq is not required (the script uses Python's json module)

GWS profile setup

The script uses the same environment variable pattern as your shell aliases. For example, if you have:

alias gws-redhat='CLOUDSDK_CONFIG="$HOME/.config/gcloud-redhat" \
  GOOGLE_WORKSPACE_CLI_CONFIG_DIR="$HOME/.config/gws-redhat" \
  GWS_CONFIG_DIR="$HOME/.config/gws-redhat" gws'

The script replicates this automatically when you pass --gws-profile redhat (the default).

NLM authentication

The nlm CLI stores its auth in ~/.notebooklm-mcp-cli/, separate from gws/gcloud configs.

# Standard login (opens built-in browser)
nlm login

# If the browser lands on NotebookLM without showing a sign-in page
nlm login --clear

# Manual login via cookies exported from Chrome DevTools
# (copy as cURL from Network tab, or copy the Cookie header)
nlm login --manual -f ~/cookies.txt

Usage

./notes-to-notebook.py -s SUBJECT [OPTIONS]

Required arguments

Flag Description
-s, --subject Meeting subject to match (e.g. "Team-A Weekly Team Sync")

Optional arguments

Flag Description
-d, --dest Destination path in Google Drive (e.g. "accounts/TeamA"). If omitted, docs are added to NotebookLM without moving or creating shortcuts.
-n, --notebook NotebookLM notebook ID. If omitted, a new notebook is created.
--not-owner Create Drive shortcuts instead of moving files (use when you don't own the docs). Requires --dest.
--gws-profile GWS config profile name (default: redhat)
--dry-run Show what would be found and moved without making changes

Examples

# Preview what would be moved (no changes made)
./notes-to-notebook.py -s "Team-A Weekly Team Sync" -d "accounts/TeamA" --dry-run

# Move notes and create a new NotebookLM notebook
./notes-to-notebook.py -s "Team-A Weekly Team Sync" -d "accounts/TeamA"

# Move notes into an existing notebook
./notes-to-notebook.py -s "Team-A Weekly Team Sync" -d "accounts/TeamA" -n abc123def456

# Just add to NotebookLM without moving (no --dest)
./notes-to-notebook.py -s "Team-A Weekly Team Sync"

# Create shortcuts instead of moving (when you don't own the docs)
./notes-to-notebook.py -s "Team-A Weekly Team Sync" -d "accounts/TeamA" --not-owner

# Use a different GWS profile
./notes-to-notebook.py -s "Cigna Standup" -d "accounts/cigna" --gws-profile personal

How it works

  1. Resolve the Drive path -- if --dest is given, walks each folder segment from root (e.g. accounts then TeamA) to find the destination folder ID.
  2. Search for matching docs -- queries for Google Docs whose name contains the subject string as a contiguous substring. When --dest is set, excludes docs already in the destination folder (safe to re-run).
  3. Create or reuse a notebook -- creates a new NotebookLM notebook titled after the subject, or uses a provided notebook ID.
  4. Move, shortcut, or just add -- for each matching doc: moves it to the destination folder (with --dest), creates a Drive shortcut there (--dest --not-owner), or simply adds it as a Drive source in NotebookLM (no --dest).
#!/usr/bin/env python3
"""
Find Gemini meeting notes in Google Drive, move them to a specified folder,
and add them as sources to a NotebookLM notebook.
"""
import argparse
import json
import os
import re
import subprocess
import sys
def build_gws_env(profile):
home = os.path.expanduser("~")
env = os.environ.copy()
env["CLOUDSDK_CONFIG"] = os.path.join(home, f".config/gcloud-{profile}")
env["GOOGLE_WORKSPACE_CLI_CONFIG_DIR"] = os.path.join(home, f".config/gws-{profile}")
env["GWS_CONFIG_DIR"] = os.path.join(home, f".config/gws-{profile}")
return env
def run_gws(args, profile):
env = build_gws_env(profile)
result = subprocess.run(
["gws"] + args, capture_output=True, text=True, env=env
)
if result.returncode != 0:
print(f"gws error: {result.stderr.strip()}", file=sys.stderr)
sys.exit(1)
try:
return json.loads(result.stdout)
except json.JSONDecodeError:
print(f"Failed to parse gws output: {result.stdout[:300]}", file=sys.stderr)
sys.exit(1)
def run_nlm(args):
result = subprocess.run(
["nlm"] + args, capture_output=True, text=True
)
if result.returncode != 0:
print(f"nlm error: {result.stderr.strip()}", file=sys.stderr)
print(result.stdout, file=sys.stderr)
sys.exit(1)
return result
def resolve_drive_path(path, profile):
"""Walk a slash-separated Drive path from root to get the folder ID."""
segments = [s for s in path.strip("/").split("/") if s]
if not segments:
print("Error: destination path is empty", file=sys.stderr)
sys.exit(1)
parent_id = "root"
for segment in segments:
escaped = segment.replace("\\", "\\\\").replace("'", "\\'")
q = (
f"name = '{escaped}' and "
f"mimeType = 'application/vnd.google-apps.folder' and "
f"'{parent_id}' in parents and "
f"trashed = false"
)
data = run_gws(
["drive", "files", "list",
"--params", json.dumps({"q": q, "fields": "files(id,name)", "pageSize": 1})],
profile,
)
files = data.get("files", [])
if not files:
print(f"Error: folder '{segment}' not found under parent {parent_id}", file=sys.stderr)
sys.exit(1)
parent_id = files[0]["id"]
print(f" Resolved '{segment}' -> {parent_id}")
return parent_id
def find_notes(subject, dest_folder_id, profile):
"""Find Google Docs whose name contains the subject, excluding the destination folder."""
escaped = subject.replace("\\", "\\\\").replace("'", "\\'")
q = (
f"name contains '{escaped}' and "
f"mimeType = 'application/vnd.google-apps.document' and "
f"trashed = false"
)
if dest_folder_id:
q = (
f"name contains '{escaped}' and "
f"mimeType = 'application/vnd.google-apps.document' and "
f"not '{dest_folder_id}' in parents and "
f"trashed = false"
)
data = run_gws(
["drive", "files", "list",
"--params", json.dumps({
"q": q,
"fields": "files(id,name,parents)",
"pageSize": 100,
})],
profile,
)
files = data.get("files", [])
return [f for f in files if subject.lower() in f.get("name", "").lower()]
def move_file(file_id, current_parents, dest_folder_id, profile):
params = {
"fileId": file_id,
"addParents": dest_folder_id,
}
if current_parents:
params["removeParents"] = ",".join(current_parents)
run_gws(["drive", "files", "update", "--params", json.dumps(params)], profile)
def create_shortcut(file_id, file_name, dest_folder_id, profile):
body = {
"name": file_name,
"mimeType": "application/vnd.google-apps.shortcut",
"parents": [dest_folder_id],
"shortcutDetails": {
"targetId": file_id,
},
}
run_gws(["drive", "files", "create", "--json", json.dumps(body)], profile)
def find_existing_shortcuts(dest_folder_id, profile):
"""Return the set of target IDs that already have shortcuts in the dest folder."""
q = (
f"mimeType = 'application/vnd.google-apps.shortcut' and "
f"'{dest_folder_id}' in parents and "
f"trashed = false"
)
data = run_gws(
["drive", "files", "list",
"--params", json.dumps({
"q": q,
"fields": "files(shortcutDetails)",
"pageSize": 100,
})],
profile,
)
return {
f["shortcutDetails"]["targetId"]
for f in data.get("files", [])
if f.get("shortcutDetails", {}).get("targetId")
}
def create_notebook(title):
result = run_nlm(["notebook", "create", title, "--json"])
try:
data = json.loads(result.stdout)
nb_id = data.get("id") or data.get("notebook_id") or data.get("project_id")
if not nb_id:
for key, val in data.items():
if "id" in key.lower() and isinstance(val, str):
nb_id = val
break
if nb_id:
return nb_id
except json.JSONDecodeError:
pass
match = re.search(r"[a-zA-Z0-9_-]{20,}", result.stdout)
if match:
return match.group(0)
print(f"Could not extract notebook ID from: {result.stdout[:300]}", file=sys.stderr)
sys.exit(1)
def add_source(notebook_id, drive_doc_id):
run_nlm(["source", "add", notebook_id, "--drive", drive_doc_id])
def main():
parser = argparse.ArgumentParser(
description="Find Gemini meeting notes, move to a Drive folder, and add to NotebookLM.",
)
parser.add_argument(
"-s", "--subject", required=True,
help="Meeting subject to match (e.g. 'Team-A Weekly Team Sync')",
)
parser.add_argument(
"-d", "--dest", default=None,
help="Destination path in Google Drive (e.g. 'accounts/TeamA'). If omitted, docs are added to NotebookLM without moving or creating shortcuts.",
)
parser.add_argument(
"-n", "--notebook", default=None,
help="NotebookLM notebook ID. If omitted, a new notebook is created.",
)
parser.add_argument(
"--gws-profile", default="redhat",
help="GWS config profile name (default: redhat)",
)
parser.add_argument(
"--not-owner", action="store_true",
help="Create shortcuts instead of moving files (use when you don't own the docs). Requires --dest.",
)
parser.add_argument(
"--dry-run", action="store_true",
help="Show what would be done without making changes",
)
args = parser.parse_args()
if args.not_owner and not args.dest:
parser.error("--not-owner requires --dest")
dest_folder_id = None
if args.dest:
print(f"Resolving Drive path: {args.dest}")
dest_folder_id = resolve_drive_path(args.dest, args.gws_profile)
print(f"Destination folder ID: {dest_folder_id}\n")
print(f"Searching for docs matching: '{args.subject}'")
if args.not_owner:
docs = find_notes(args.subject, None, args.gws_profile)
existing = find_existing_shortcuts(dest_folder_id, args.gws_profile)
docs = [d for d in docs if d["id"] not in existing]
else:
docs = find_notes(args.subject, dest_folder_id, args.gws_profile)
if not docs:
print("No matching documents found.")
return
if args.not_owner:
action = "shortcut"
elif args.dest:
action = "move"
else:
action = "add"
print(f"Found {len(docs)} document(s) to {action}:")
for doc in docs:
print(f" - {doc['name']} ({doc['id']})")
print()
if args.dry_run:
print(f"[DRY RUN] Would {action} the above documents and add them to NotebookLM.")
return
notebook_id = args.notebook
if not notebook_id:
print(f"Creating new NotebookLM notebook: '{args.subject}'")
notebook_id = create_notebook(args.subject)
print(f"Created notebook: {notebook_id}\n")
else:
print(f"Using notebook: {notebook_id}\n")
for doc in docs:
doc_id = doc["id"]
doc_name = doc["name"]
parents = doc.get("parents", [])
print(f"Processing: {doc_name}")
if args.not_owner:
print(f" Creating shortcut in {args.dest} ...")
create_shortcut(doc_id, doc_name, dest_folder_id, args.gws_profile)
elif args.dest:
print(f" Moving to {args.dest} ...")
move_file(doc_id, parents, dest_folder_id, args.gws_profile)
print(f" Adding to NotebookLM ...")
add_source(notebook_id, doc_id)
print(f" Done.")
print(f"\nComplete. Processed {len(docs)} document(s).")
print(f"Notebook ID: {notebook_id}")
if __name__ == "__main__":
main()
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment