Skip to content

Commit b1220c8

Browse files
authored
Merge pull request teng-lin#197 from teng-lin/issue-112
fix(cli): add explicit source delete-by-title command
2 parents 3a40a67 + 2ccb9bc commit b1220c8

File tree

7 files changed

+449
-5
lines changed

7 files changed

+449
-5
lines changed

docs/cli-reference.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,8 +102,11 @@ Supported source types: URLs, YouTube videos, files (PDF, text, Markdown, Word,
102102
| `rename <id> <title>` | Source ID, new title | - | `source rename src123 "New Name"` |
103103
| `refresh <id>` | Source ID | - | `source refresh src123` |
104104
| `delete <id>` | Source ID | - | `source delete src123` |
105+
| `delete-by-title <title>` | Exact source title | - | `source delete-by-title "My Source"` |
105106
| `wait <id>` | Source ID | `--timeout`, `--interval` | `source wait src123` |
106107

108+
`source delete <id>` accepts only full source IDs or unique partial-ID prefixes. To delete by exact source title, use `source delete-by-title "<title>"`.
109+
107110
### Research Commands (`notebooklm research <cmd>`)
108111

109112
| Command | Arguments | Options | Example |

docs/troubleshooting.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -210,6 +210,8 @@ If the title contains error-related text, remove the source and use the pre-fetc
210210
```bash
211211
# Remove incorrectly parsed source
212212
notebooklm source delete <source_id>
213+
# Or, if you only have the exact title:
214+
notebooklm source delete-by-title "Exact Source Title"
213215

214216
# Then re-add using the bird CLI method above
215217
```

src/notebooklm/cli/source.py

Lines changed: 122 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,15 @@
88
guide Get AI-generated source summary and keywords
99
stale Check if a URL/Drive source needs refresh
1010
delete Delete a source
11+
delete-by-title Delete a source by exact title
1112
rename Rename a source
1213
refresh Refresh a URL/Drive source
1314
add-drive Add a Google Drive document
1415
add-research Search web/drive and add sources from results
1516
"""
1617

1718
import asyncio
19+
import re
1820
from pathlib import Path
1921

2022
import click
@@ -33,6 +35,7 @@
3335
require_notebook,
3436
resolve_notebook_id,
3537
resolve_source_id,
38+
validate_id,
3639
with_client,
3740
)
3841

@@ -50,6 +53,7 @@ def source():
5053
guide Get AI-generated source summary and keywords
5154
stale Check if source needs refresh
5255
delete Delete a source
56+
delete-by-title Delete a source by exact title
5357
rename Rename a source
5458
refresh Refresh a URL/Drive source
5559
@@ -61,6 +65,91 @@ def source():
6165
pass
6266

6367

68+
def _build_id_ambiguity_error(source_id: str, matches) -> click.ClickException:
69+
"""Build a consistent ambiguity error for source ID prefix matches."""
70+
lines = [f"Ambiguous ID '{source_id}' matches {len(matches)} sources:"]
71+
for item in matches[:5]:
72+
title = item.title or "(untitled)"
73+
lines.append(f" {item.id[:12]}... {title}")
74+
if len(matches) > 5:
75+
lines.append(f" ... and {len(matches) - 5} more")
76+
lines.append("Specify more characters to narrow down.")
77+
return click.ClickException("\n".join(lines))
78+
79+
80+
def _looks_like_full_source_id(source_id: str) -> bool:
81+
"""Return True for UUID-shaped source IDs that can skip list-based resolution."""
82+
return bool(
83+
re.fullmatch(
84+
r"[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}",
85+
source_id,
86+
)
87+
)
88+
89+
90+
async def _resolve_source_for_delete(client, notebook_id: str, source_id: str) -> str:
91+
"""Resolve a source ID for delete, returning the full source ID string.
92+
93+
Canonical UUIDs take a fast path and skip the live source list lookup.
94+
Partial IDs are resolved against the live list.
95+
"""
96+
source_id = validate_id(source_id, "source")
97+
if _looks_like_full_source_id(source_id):
98+
return source_id
99+
100+
sources = await client.sources.list(notebook_id)
101+
matches = [item for item in sources if item.id.lower().startswith(source_id.lower())]
102+
103+
if len(matches) == 1:
104+
if matches[0].id != source_id:
105+
title = matches[0].title or "(untitled)"
106+
console.print(f"[dim]Matched: {matches[0].id[:12]}... ({title})[/dim]")
107+
return matches[0].id
108+
109+
if len(matches) > 1:
110+
raise _build_id_ambiguity_error(source_id, matches)
111+
112+
title_matches = [item for item in sources if item.title == source_id]
113+
if title_matches:
114+
lines = [
115+
f"'{source_id}' matches {len(title_matches)} source title(s), not source IDs.",
116+
f"Use 'notebooklm source delete-by-title \"{source_id}\"' or delete by ID:",
117+
]
118+
for item in title_matches[:5]:
119+
lines.append(f" {item.id[:12]}... {item.title}")
120+
if len(title_matches) > 5:
121+
lines.append(f" ... and {len(title_matches) - 5} more")
122+
raise click.ClickException("\n".join(lines))
123+
124+
raise click.ClickException(
125+
f"No source found starting with '{source_id}'. "
126+
"Run 'notebooklm source list' to see available sources."
127+
)
128+
129+
130+
async def _resolve_source_by_exact_title(client, notebook_id: str, title: str):
131+
"""Resolve a source by exact title for the explicit delete-by-title flow."""
132+
title = validate_id(title, "source title")
133+
sources = await client.sources.list(notebook_id)
134+
matches = [item for item in sources if item.title == title]
135+
136+
if len(matches) == 1:
137+
return matches[0]
138+
139+
if len(matches) > 1:
140+
lines = [f"Title '{title}' matches {len(matches)} sources. Delete by ID instead:"]
141+
for item in matches[:5]:
142+
lines.append(f" {item.id[:12]}... {item.title}")
143+
if len(matches) > 5:
144+
lines.append(f" ... and {len(matches) - 5} more")
145+
raise click.ClickException("\n".join(lines))
146+
147+
raise click.ClickException(
148+
f"No source found with title '{title}'. "
149+
"Run 'notebooklm source list' to see available sources."
150+
)
151+
152+
64153
@source.command("list")
65154
@click.option(
66155
"-n",
@@ -274,8 +363,7 @@ def source_delete(ctx, source_id, notebook_id, yes, client_auth):
274363
async def _run():
275364
async with NotebookLMClient(client_auth) as client:
276365
nb_id_resolved = await resolve_notebook_id(client, nb_id)
277-
# Resolve partial ID to full ID
278-
resolved_id = await resolve_source_id(client, nb_id_resolved, source_id)
366+
resolved_id = await _resolve_source_for_delete(client, nb_id_resolved, source_id)
279367

280368
if not yes and not click.confirm(f"Delete source {resolved_id}?"):
281369
return
@@ -289,6 +377,38 @@ async def _run():
289377
return _run()
290378

291379

380+
@source.command("delete-by-title")
381+
@click.argument("title")
382+
@click.option(
383+
"-n",
384+
"--notebook",
385+
"notebook_id",
386+
default=None,
387+
help="Notebook ID (uses current if not set)",
388+
)
389+
@click.option("--yes", "-y", is_flag=True, help="Skip confirmation")
390+
@with_client
391+
def source_delete_by_title(ctx, title, notebook_id, yes, client_auth):
392+
"""Delete a source by exact title."""
393+
nb_id = require_notebook(notebook_id)
394+
395+
async def _run():
396+
async with NotebookLMClient(client_auth) as client:
397+
nb_id_resolved = await resolve_notebook_id(client, nb_id)
398+
source = await _resolve_source_by_exact_title(client, nb_id_resolved, title)
399+
400+
if not yes and not click.confirm(f"Delete source '{source.title}' ({source.id})?"):
401+
return
402+
403+
success = await client.sources.delete(nb_id_resolved, source.id)
404+
if success:
405+
console.print(f"[green]Deleted source:[/green] {source.id}")
406+
else:
407+
console.print("[yellow]Delete may have failed[/yellow]")
408+
409+
return _run()
410+
411+
292412
@source.command("rename")
293413
@click.argument("source_id")
294414
@click.argument("new_title")

src/notebooklm/data/SKILL.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,8 @@ Before starting workflows, verify the CLI is ready:
129129
| Add file | `notebooklm source add ./file.pdf` |
130130
| Add YouTube | `notebooklm source add "https://youtube.com/..."` |
131131
| List sources | `notebooklm source list` |
132+
| Delete source by ID | `notebooklm source delete <source_id>` |
133+
| Delete source by exact title | `notebooklm source delete-by-title "Exact Title"` |
132134
| Wait for source processing | `notebooklm source wait <source_id>` |
133135
| Web research (fast) | `notebooklm source add-research "query"` |
134136
| Web research (deep) | `notebooklm source add-research "query" --mode deep --no-wait` |
@@ -173,7 +175,7 @@ Before starting workflows, verify the CLI is ready:
173175

174176
**Parallel safety:** Use explicit notebook IDs in parallel workflows. Commands supporting `-n` shorthand: `artifact wait`, `source wait`, `research wait/status`, `download *`. Download commands also support `-a/--artifact`. Other commands use `--notebook`. For chat, use `-c <conversation_id>` to target a specific conversation.
175177

176-
**Partial IDs:** Use first 6+ characters of UUIDs. Must be unique prefix (fails if ambiguous). Works for: `use`, `delete`, `wait` commands. For automation, prefer full UUIDs to avoid ambiguity.
178+
**Partial IDs:** Use first 6+ characters of UUIDs. Must be unique prefix (fails if ambiguous). Works for ID-based commands such as `use`, `source delete`, and `wait`. For exact source-title deletion, use `source delete-by-title "Title"`. For automation, prefer full UUIDs to avoid ambiguity.
177179

178180
## Command Output Formats
179181

tests/integration/cli_vcr/test_sources.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,3 +78,23 @@ def test_source_content(self, runner, mock_auth_for_vcr, mock_context, command,
7878
with notebooklm_vcr.use_cassette(cassette):
7979
result = runner.invoke(cli, ["source", command, "test_source_id"])
8080
assert_command_success(result)
81+
82+
83+
class TestSourceDeleteCommand:
84+
"""Test delete command paths that can reuse existing VCR coverage."""
85+
86+
@notebooklm_vcr.use_cassette("sources_delete.yaml")
87+
def test_source_delete_full_uuid(self, runner, mock_auth_for_vcr, mock_context):
88+
"""Delete source by full UUID works with real client."""
89+
result = runner.invoke(
90+
cli,
91+
[
92+
"source",
93+
"delete",
94+
"ff503bfa-5e39-4281-a1d8-2a66c7b86724",
95+
"-n",
96+
"06f0c5bd-108f-4c8b-8911-34b2acc656de",
97+
"-y",
98+
],
99+
)
100+
assert_command_success(result, allow_no_context=False)
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
"""Integration tests for source delete CLI flows."""
2+
3+
import json
4+
from pathlib import Path
5+
from unittest.mock import AsyncMock, patch
6+
7+
import pytest
8+
from click.testing import CliRunner
9+
from pytest_httpx import HTTPXMock
10+
11+
from notebooklm.notebooklm_cli import cli
12+
from notebooklm.rpc import RPCMethod
13+
14+
15+
@pytest.fixture
16+
def runner() -> CliRunner:
17+
"""Create a Click test runner."""
18+
return CliRunner()
19+
20+
21+
@pytest.fixture
22+
def mock_auth():
23+
"""Mock authentication for CLI integration tests."""
24+
with (
25+
patch(
26+
"notebooklm.cli.helpers.load_auth_from_storage",
27+
return_value={
28+
"SID": "test",
29+
"HSID": "test",
30+
"SSID": "test",
31+
"APISID": "test",
32+
"SAPISID": "test",
33+
},
34+
),
35+
patch("notebooklm.cli.helpers.fetch_tokens", new_callable=AsyncMock) as mock_fetch,
36+
):
37+
mock_fetch.return_value = ("csrf_token", "session_id")
38+
yield
39+
40+
41+
@pytest.fixture
42+
def mock_context(tmp_path: Path):
43+
"""Provide a canonical notebook UUID so CLI skips notebook-list resolution."""
44+
context_file = tmp_path / "context.json"
45+
context_file.write_text(
46+
json.dumps({"notebook_id": "06f0c5bd-108f-4c8b-8911-34b2acc656de"}),
47+
encoding="utf-8",
48+
)
49+
50+
with patch("notebooklm.cli.helpers.get_context_path", return_value=context_file):
51+
yield context_file
52+
53+
54+
def _build_source_list_response(build_rpc_response, source_id: str, title: str) -> str:
55+
"""Build a GET_NOTEBOOK response containing a single source."""
56+
return build_rpc_response(
57+
RPCMethod.GET_NOTEBOOK,
58+
[
59+
[
60+
"Test Notebook",
61+
[
62+
[
63+
[source_id],
64+
title,
65+
[None, 11, [1704067200, 0], None, 5, None, None, None],
66+
[None, 2],
67+
]
68+
],
69+
"06f0c5bd-108f-4c8b-8911-34b2acc656de",
70+
"📘",
71+
None,
72+
[None, None, None, None, None, [1704067200, 0]],
73+
]
74+
],
75+
)
76+
77+
78+
class TestCliSourceDeleteIntegration:
79+
"""Integration coverage for CLI source delete flows."""
80+
81+
def test_source_delete_by_title(
82+
self, runner, mock_auth, mock_context, httpx_mock: HTTPXMock, build_rpc_response
83+
):
84+
httpx_mock.add_response(
85+
content=_build_source_list_response(
86+
build_rpc_response,
87+
"ff503bfa-5e39-4281-a1d8-2a66c7b86724",
88+
"VCR Delete Test Source",
89+
).encode()
90+
)
91+
httpx_mock.add_response(
92+
content=build_rpc_response(RPCMethod.DELETE_SOURCE, [True]).encode()
93+
)
94+
95+
result = runner.invoke(
96+
cli,
97+
["source", "delete-by-title", "VCR Delete Test Source", "-y"],
98+
)
99+
100+
assert result.exit_code == 0
101+
assert "Deleted source" in result.output
102+
103+
def test_source_delete_title_suggests_delete_by_title(
104+
self, runner, mock_auth, mock_context, httpx_mock: HTTPXMock, build_rpc_response
105+
):
106+
httpx_mock.add_response(
107+
content=_build_source_list_response(
108+
build_rpc_response,
109+
"ff503bfa-5e39-4281-a1d8-2a66c7b86724",
110+
"VCR Delete Test Source",
111+
).encode()
112+
)
113+
114+
result = runner.invoke(
115+
cli,
116+
["source", "delete", "VCR Delete Test Source", "-y"],
117+
)
118+
119+
assert result.exit_code == 1
120+
assert "delete-by-title" in result.output

0 commit comments

Comments
 (0)