gogkeep 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
gogkeep-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Evgeny Yakimov
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
gogkeep-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,17 @@
1
+ Metadata-Version: 2.4
2
+ Name: gogkeep
3
+ Version: 0.1.0
4
+ Summary: CLI interface for Google Keep matching gog style
5
+ Author-email: John <john@example.com>
6
+ Requires-Python: >=3.12
7
+ License-File: LICENSE
8
+ Requires-Dist: gkeepapi
9
+ Requires-Dist: gpsoauth
10
+ Requires-Dist: click
11
+ Requires-Dist: tabulate
12
+ Requires-Dist: keyring
13
+ Requires-Dist: keyrings.alt
14
+ Requires-Dist: keyring-pass
15
+ Requires-Dist: cryptography
16
+ Requires-Dist: websockets>=16.0
17
+ Dynamic: license-file
@@ -0,0 +1,72 @@
1
+ # gogkeep
2
+
3
+ Google Keep CLI matching the style and interface of [gogcli](https://github.com/steipete/gogcli).
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ pip install .
9
+ ```
10
+
11
+ Or for `pipx`:
12
+ ```bash
13
+ pipx install .
14
+ ```
15
+
16
+ ## Authentication (Master Token Auth workflow)
17
+
18
+ Google Keep API access via `gkeepapi` requires a Master Token. To obtain it, use the `login` command:
19
+
20
+ ```bash
21
+ gogkeep login --account your-email@gmail.com
22
+ ```
23
+
24
+ Follow the instructions:
25
+ 1. Visit [https://accounts.google.com/EmbeddedSetup](https://accounts.google.com/EmbeddedSetup) in your browser.
26
+ 2. Log in to your Google account.
27
+ 3. Open Developer Tools (F12) -> Application/Storage -> Cookies.
28
+ 4. Find the `oauth_token` cookie (it starts with `oauth2_4/`).
29
+ 5. Copy its value and paste it into the prompt.
30
+
31
+ The Master Token will be saved to `REDACTED_CREDS_DIR/google_oauth_master_token` for future use.
32
+
33
+ ## Usage
34
+
35
+ ### Root Flags
36
+ - `-a, --account EMAIL`: Set the account email (also can be set via `GOG_ACCOUNT` env var).
37
+ - `-j, --json`: Output as JSON.
38
+ - `-p, --plain`: Output as tab-separated values.
39
+ - `-n, --dry-run`: Do not make changes (mostly for `create` and `delete`).
40
+
41
+ ### Commands
42
+
43
+ #### List Notes
44
+ ```bash
45
+ gogkeep list --account your-email@gmail.com
46
+ ```
47
+
48
+ #### Get Note
49
+ ```bash
50
+ gogkeep get <note_id> --account your-email@gmail.com
51
+ ```
52
+
53
+ #### Search Notes
54
+ ```bash
55
+ gogkeep search "query text" --account your-email@gmail.com
56
+ ```
57
+
58
+ #### Create Note
59
+ ```bash
60
+ gogkeep create --title "My Title" --text "My body text" --account your-email@gmail.com
61
+ ```
62
+
63
+ #### Delete Note
64
+ ```bash
65
+ gogkeep delete <note_id> --account your-email@gmail.com
66
+ ```
67
+
68
+ #### Download Attachment
69
+ ```bash
70
+ gogkeep attachment <attachment_name> --out local_filename.jpg --account your-email@gmail.com
71
+ ```
72
+ Attachment names are in the format `notes/<note_id>/attachments/<attachment_id>`.
@@ -0,0 +1,40 @@
1
+ [build-system]
2
+ requires = ["setuptools>=61.0"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "gogkeep"
7
+ version = "0.1.0"
8
+ description = "CLI interface for Google Keep matching gog style"
9
+ authors = [{ name = "John", email = "john@example.com" }]
10
+ dependencies = [
11
+ "gkeepapi",
12
+ "gpsoauth",
13
+ "click",
14
+ "tabulate",
15
+ "keyring",
16
+ "keyrings.alt",
17
+ "keyring-pass",
18
+ "cryptography",
19
+ "websockets>=16.0",
20
+ ]
21
+ requires-python = ">=3.12"
22
+
23
+ [project.scripts]
24
+ gogkeep = "gogkeep.cli:main"
25
+
26
+ [tool.setuptools.packages.find]
27
+ where = ["src"]
28
+
29
+ [tool.ruff]
30
+ line-length = 120
31
+ target-version = "py312"
32
+
33
+ [tool.ruff.lint]
34
+ extend-select = ["I", "UP", "B", "SIM", "N", "RUF"]
35
+
36
+ [dependency-groups]
37
+ dev = [
38
+ "pytest>=9.0.3",
39
+ "ruff>=0.15.10",
40
+ ]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1 @@
1
+ # gogkeep package
@@ -0,0 +1,4 @@
1
+ from .cli import main
2
+
3
+ if __name__ == "__main__":
4
+ main()
@@ -0,0 +1,75 @@
1
+ import click
2
+ import gpsoauth
3
+
4
+ from .store import get_token, set_default_account, set_token
5
+
6
+
7
+ def get_master_token(email: str):
8
+ try:
9
+ return get_token("default", email)
10
+ except KeyError:
11
+ return None
12
+
13
+
14
+ def save_master_token(email: str, token: str):
15
+ set_token("default", email, token)
16
+ set_default_account(email, "default")
17
+
18
+
19
+ def login_flow(email: str | None = None):
20
+ from .cdp_auth import find_chrome, get_auth_info_via_browser
21
+
22
+ click.echo("--- Google Keep Authentication (Master Token Auth workflow) ---")
23
+
24
+ oauth_token = None
25
+ discovered_email = None
26
+ chrome_path = find_chrome()
27
+
28
+ if chrome_path:
29
+ click.echo(f"Found compatible browser: {chrome_path}")
30
+ if click.confirm("Attempt to automate token extraction via browser?"):
31
+ click.echo("Launching browser... Please log in to your Google account.")
32
+ discovered_email, oauth_token = get_auth_info_via_browser()
33
+ if oauth_token:
34
+ click.echo("Successfully extracted token from browser.")
35
+ if discovered_email:
36
+ click.echo(f"Detected account: {discovered_email}")
37
+ email = discovered_email
38
+ else:
39
+ click.echo("Failed to extract token automatically.")
40
+
41
+ if not oauth_token:
42
+ click.echo("Manual fallback initialization...")
43
+ if not email:
44
+ email = click.prompt("Enter your Google account email")
45
+ click.echo(f"Account: {email}")
46
+ click.echo("1. Visit https://accounts.google.com/EmbeddedSetup in your browser.")
47
+ click.echo("2. Log in to your Google account.")
48
+ click.echo("3. Open Developer Tools (F12) -> Application/Storage -> Cookies.")
49
+ click.echo("4. Find the 'oauth_token' cookie (starts with 'oauth2_4/').")
50
+ click.echo("5. Copy its value.")
51
+ oauth_token = click.prompt("Enter the 'oauth_token' (oauth2_4/...)")
52
+
53
+ # Final check for email if discovery + manual both missed it
54
+ if not email:
55
+ email = click.prompt("Enter the email address associated with this token")
56
+
57
+ # Use the specific android_id from the gist, some token exchanges require this exact string format or it throws BadAuthentication.
58
+ android_id = "0123456789abcdef"
59
+
60
+ click.echo(f"Exchanging token for Master Token (using android_id: {android_id})...")
61
+
62
+ try:
63
+ response = gpsoauth.exchange_token(email, oauth_token, android_id)
64
+ if "Token" in response:
65
+ master_token = response["Token"]
66
+ save_master_token(email, master_token)
67
+ click.echo(f"Successfully saved Master Token in keyring for {email}")
68
+ return master_token
69
+ else:
70
+ click.echo("Failed to exchange token. Response from Google:")
71
+ click.echo(response)
72
+ return None
73
+ except Exception as e:
74
+ click.echo(f"An error occurred: {e}")
75
+ return None
@@ -0,0 +1,199 @@
1
+ import asyncio
2
+ import http.client
3
+ import json
4
+ import os
5
+ import shutil
6
+ import subprocess
7
+ import tempfile
8
+ import time
9
+
10
+ import websockets
11
+ from websockets.exceptions import ConnectionClosed
12
+
13
+
14
+ def find_chrome() -> str | None:
15
+ """Find a compatible Chrome or Chromium binary."""
16
+ for bin_name in ["google-chrome", "chromium-browser", "chromium", "chrome"]:
17
+ path = shutil.which(bin_name)
18
+ if path:
19
+ return path
20
+ return None
21
+
22
+
23
+ def is_port_open(port: int) -> bool:
24
+ import socket
25
+
26
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
27
+ s.settimeout(0.1)
28
+ return s.connect_ex(("localhost", port)) == 0
29
+
30
+
31
+ def get_websocket_url(port: int) -> str | None:
32
+ try:
33
+ conn = http.client.HTTPConnection("localhost", port)
34
+ conn.request("GET", "/json")
35
+ resp = conn.getresponse()
36
+ if resp.status == 200:
37
+ data = json.loads(resp.read().decode())
38
+ best_target = None
39
+ for item in data:
40
+ if item.get("type") == "page" and "webSocketDebuggerUrl" in item:
41
+ url = item.get("url", "")
42
+ title = item.get("title", "")
43
+ if "accounts.google.com" in url or "google" in title.lower():
44
+ return item["webSocketDebuggerUrl"]
45
+ best_target = item["webSocketDebuggerUrl"]
46
+ return best_target
47
+ except Exception:
48
+ pass
49
+ return None
50
+
51
+
52
+ async def watch_auth_info(ws_url: str) -> tuple[str | None, str | None]:
53
+ print(f"[*] Attaching to CDP: {ws_url}")
54
+ try:
55
+ async with websockets.connect(ws_url, ping_interval=None) as ws:
56
+ next_id = 1
57
+
58
+ async def call_cdp(method: str, params: dict | None = None) -> dict:
59
+ nonlocal next_id
60
+ req_id = next_id
61
+ next_id += 1
62
+ msg = {"id": req_id, "method": method}
63
+ if params:
64
+ msg["params"] = params
65
+ await ws.send(json.dumps(msg))
66
+
67
+ # Consume messages until we find our response
68
+ while True:
69
+ resp_str = await ws.recv()
70
+ resp = json.loads(resp_str)
71
+ if resp.get("id") == req_id:
72
+ return resp
73
+
74
+ await call_cdp("Runtime.enable")
75
+ await call_cdp("Storage.enable")
76
+ print("[*] CDP domains enabled (Runtime, Storage)")
77
+
78
+ email = None
79
+ token = None
80
+ start_time = time.time()
81
+
82
+ while (time.time() - start_time) < 600: # 10 minute timeout
83
+ try:
84
+ # 0. Get current URL
85
+ await call_cdp("Runtime.evaluate", {"expression": "window.location.href"})
86
+
87
+ # 1. Get cookies (Storage)
88
+ storage_resp = await call_cdp("Storage.getCookies")
89
+
90
+ all_cookies = []
91
+ all_cookies.extend(storage_resp.get("result", {}).get("cookies", []))
92
+
93
+ found_names = set()
94
+ for c in all_cookies:
95
+ found_names.add(c["name"])
96
+ if c["name"] == "oauth_token":
97
+ token = c["value"]
98
+ if c["name"] == "Email" and not email:
99
+ email = c["value"]
100
+
101
+ # 2. Extract email from AF_initDataCallback (High reliability)
102
+ if not email:
103
+ js = """
104
+ (function() {
105
+ const scripts = Array.from(document.querySelectorAll('script'));
106
+ for (const s of scripts) {
107
+ if (s.innerText.includes('AF_initDataCallback')) {
108
+ try {
109
+ let match = s.innerText.match(/AF_initDataCallback\\(([\\s\\S]*?)\\);?/);
110
+ if (match) {
111
+ let cbData = null;
112
+ const AF_initDataCallback = (obj) => { cbData = obj; };
113
+ eval('AF_initDataCallback(' + match[1] + ')');
114
+ if (cbData && cbData.data && cbData.data[0] && typeof cbData.data[0][0] === 'string' && cbData.data[0][0].includes('@')) {
115
+ return cbData.data[0][0];
116
+ }
117
+ }
118
+ } catch(e) {}
119
+
120
+ // Fallback to regex
121
+ const emailMatch = s.innerText.match(/[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}/);
122
+ if (emailMatch) return emailMatch[0];
123
+ }
124
+ }
125
+ return null;
126
+ })()
127
+ """
128
+ email_resp = await call_cdp("Runtime.evaluate", {"expression": js})
129
+ res = email_resp.get("result", {}).get("result", {})
130
+ if res.get("type") == "string" and res.get("value"):
131
+ email = res.get("value")
132
+ print(f"[*] Account detected via page data: {email}")
133
+
134
+ if token:
135
+ print(f"[!] Success: oauth_token found! (Email: {email or 'Unknown'})")
136
+ return email, token
137
+
138
+ except ConnectionClosed:
139
+ print("[!] Browser connection closed.")
140
+ break
141
+
142
+ await asyncio.sleep(2)
143
+
144
+ except Exception as e:
145
+ print(f"[!] CDP Error: {e}")
146
+
147
+ return None, None
148
+
149
+
150
+ def find_free_port() -> int:
151
+ import socket
152
+
153
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
154
+ s.bind(("", 0))
155
+ return s.getsockname()[1]
156
+
157
+
158
+ def get_auth_info_via_browser() -> tuple[str | None, str | None]:
159
+ chrome_path = find_chrome()
160
+ if not chrome_path:
161
+ return None, None
162
+
163
+ port = find_free_port()
164
+ temp_profile_dir = tempfile.mkdtemp(prefix="gogkeep-auth-")
165
+ url = "https://accounts.google.com/EmbeddedSetup"
166
+
167
+ cmd = [
168
+ chrome_path,
169
+ f"--remote-debugging-port={port}",
170
+ f"--user-data-dir={temp_profile_dir}",
171
+ "--no-first-run",
172
+ "--no-default-browser-check",
173
+ url,
174
+ ]
175
+
176
+ proc = subprocess.Popen(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
177
+
178
+ try:
179
+ ws_url = None
180
+ for _ in range(20):
181
+ if is_port_open(port):
182
+ ws_url = get_websocket_url(port)
183
+ if ws_url:
184
+ break
185
+ time.sleep(0.5)
186
+
187
+ if not ws_url:
188
+ return None, None
189
+
190
+ return asyncio.run(watch_auth_info(ws_url))
191
+
192
+ finally:
193
+ proc.terminate()
194
+ try:
195
+ proc.wait(timeout=5)
196
+ except Exception:
197
+ proc.kill()
198
+ if os.path.exists(temp_profile_dir):
199
+ shutil.rmtree(temp_profile_dir)
@@ -0,0 +1,307 @@
1
+ import json
2
+ import os
3
+ import sys
4
+
5
+ import click
6
+ from tabulate import tabulate
7
+
8
+ from .auth import login_flow
9
+ from .keep import create_note, delete_note, get_keep, get_note, list_notes, search_notes
10
+
11
+
12
+ def resolve_account(account):
13
+ final_account = account or os.getenv("GOGKEEP_ACCOUNT")
14
+ if not final_account:
15
+ try:
16
+ from .store import get_default_account
17
+
18
+ final_account = get_default_account("default")
19
+ except Exception:
20
+ final_account = None
21
+ return final_account
22
+
23
+
24
+ class RootFlags:
25
+ def __init__(self, account, output_json=False, plain=False, verbose=False, dry_run=False):
26
+ self.account = account
27
+ self.output_json = output_json
28
+ self.plain = plain
29
+ self.verbose = verbose
30
+ self.dry_run = dry_run
31
+
32
+
33
+ @click.group()
34
+ @click.option("--account", "-a", help="Account email for Keep API")
35
+ @click.option("--json", "-j", "output_json", is_flag=True, help="Output JSON to stdout")
36
+ @click.option("--plain", "-p", is_flag=True, help="Output stable, parseable text to stdout")
37
+ @click.option("--verbose", "-v", is_flag=True, help="Enable verbose logging")
38
+ @click.option("--dry-run", "-n", is_flag=True, help="Do not make changes")
39
+ @click.pass_context
40
+ def main(ctx, account, output_json, plain, verbose, dry_run):
41
+ """Google Keep CLI matching gog style."""
42
+ final_account = resolve_account(account)
43
+ ctx.obj = RootFlags(final_account, output_json, plain, verbose, dry_run)
44
+
45
+
46
+ @main.command()
47
+ @click.pass_obj
48
+ def login(root_flags):
49
+ """Obtain a Master Token for Keep API access."""
50
+ login_flow(root_flags.account)
51
+
52
+
53
+ def format_date(dt):
54
+ # Match gogcli's time format if possible.
55
+ # Usually it's RFC3339-ish.
56
+ return dt.isoformat()
57
+
58
+
59
+ def note_snippet(note):
60
+ text = getattr(note, "text", "")
61
+ if not text and hasattr(note, "items"):
62
+ text = " ".join([f"- {i.text}" for i in note.items[:3]])
63
+ text = text.replace("\n", " ").strip()
64
+ if len(text) > 50:
65
+ text = text[:50] + "..."
66
+ return text or "(no content)"
67
+
68
+
69
+ def print_notes(notes, output_json, plain):
70
+ if output_json:
71
+ data = {
72
+ "notes": [
73
+ {
74
+ "id": n.id,
75
+ "name": f"notes/{n.id}",
76
+ "title": n.title,
77
+ "updated": format_date(n.timestamps.updated),
78
+ "created": format_date(n.timestamps.created),
79
+ "text": getattr(n, "text", None),
80
+ "trashed": n.trashed,
81
+ }
82
+ for n in notes
83
+ ]
84
+ }
85
+ click.echo(json.dumps(data, indent=2))
86
+ return
87
+
88
+ if not notes:
89
+ click.echo("No notes", err=True)
90
+ return
91
+
92
+ table_data = []
93
+ for n in notes:
94
+ title = n.title or note_snippet(n)
95
+ table_data.append([f"notes/{n.id}", title, format_date(n.timestamps.updated)])
96
+
97
+ if plain:
98
+ for row in table_data:
99
+ click.echo("\t".join(map(str, row)))
100
+ else:
101
+ click.echo(tabulate(table_data, headers=["NAME", "TITLE", "UPDATED"], tablefmt="plain", disable_numparse=True))
102
+
103
+
104
+ @main.command(name="list")
105
+ @click.option("--max", "limit", default=100, help="Max results")
106
+ @click.pass_obj
107
+ def list_cmd(root_flags, limit):
108
+ """List notes."""
109
+ if not root_flags.account:
110
+ click.echo("Error: --account is required.", err=True)
111
+ sys.exit(1)
112
+
113
+ try:
114
+ keep = get_keep(root_flags.account)
115
+ notes = list_notes(keep, max_results=limit)
116
+ print_notes(notes, root_flags.output_json, root_flags.plain)
117
+ except Exception as e:
118
+ click.echo(f"Error: {e}", err=True)
119
+ sys.exit(1)
120
+
121
+
122
+ @main.command()
123
+ @click.argument("query")
124
+ @click.pass_obj
125
+ def search(root_flags, query):
126
+ """Search notes by text."""
127
+ if not root_flags.account:
128
+ click.echo("Error: --account is required.", err=True)
129
+ sys.exit(1)
130
+
131
+ try:
132
+ keep = get_keep(root_flags.account)
133
+ notes = search_notes(keep, query)
134
+ print_notes(notes, root_flags.output_json, root_flags.plain)
135
+ except Exception as e:
136
+ click.echo(f"Error: {e}", err=True)
137
+ sys.exit(1)
138
+
139
+
140
+ @main.command()
141
+ @click.argument("note_id")
142
+ @click.pass_obj
143
+ def get(root_flags, note_id):
144
+ """Get a note's details."""
145
+ if not root_flags.account:
146
+ click.echo("Error: --account is required.", err=True)
147
+ sys.exit(1)
148
+
149
+ try:
150
+ keep = get_keep(root_flags.account)
151
+ note = get_note(keep, note_id)
152
+ if not note:
153
+ click.echo(f"Note not found: {note_id}", err=True)
154
+ sys.exit(1)
155
+
156
+ if root_flags.output_json:
157
+ click.echo(
158
+ json.dumps(
159
+ {
160
+ "id": note.id,
161
+ "name": f"notes/{note.id}",
162
+ "title": note.title,
163
+ "created": format_date(note.timestamps.created),
164
+ "updated": format_date(note.timestamps.updated),
165
+ "text": getattr(note, "text", None),
166
+ "items": [{"text": i.text, "checked": i.checked} for i in note.items]
167
+ if hasattr(note, "items")
168
+ else None,
169
+ "trashed": note.trashed,
170
+ },
171
+ indent=2,
172
+ )
173
+ )
174
+ return
175
+
176
+ click.echo(f"name\tnotes/{note.id}")
177
+ click.echo(f"title\t{note.title}")
178
+ click.echo(f"created\t{format_date(note.timestamps.created)}")
179
+ click.echo(f"updated\t{format_date(note.timestamps.updated)}")
180
+ click.echo(f"trashed\t{note.trashed}")
181
+
182
+ click.echo("")
183
+ if hasattr(note, "text") and note.text:
184
+ click.echo(note.text)
185
+ elif hasattr(note, "items"):
186
+ for item in note.items:
187
+ status = "[x]" if item.checked else "[ ]"
188
+ click.echo(f"{status} {item.text}")
189
+
190
+ # Attachments (images, audio, etc)
191
+ blobs = []
192
+ if hasattr(note, "images"):
193
+ blobs.extend(note.images)
194
+ if hasattr(note, "drawings"):
195
+ blobs.extend(note.drawings)
196
+ if hasattr(note, "audio"):
197
+ blobs.extend(note.audio)
198
+ if blobs:
199
+ click.echo("")
200
+ click.echo(f"attachments\t{len(blobs)}")
201
+ for b in blobs:
202
+ click.echo(f" notes/{note.id}/attachments/{b.id}\t{getattr(b, 'type', 'blob')}")
203
+ except Exception as e:
204
+ click.echo(f"Error: {e}", err=True)
205
+ sys.exit(1)
206
+
207
+
208
+ @main.command()
209
+ @click.option("--title", help="Note title")
210
+ @click.option("--text", help="Note body text")
211
+ @click.option("--item", "items", multiple=True, help="List item text (repeatable)")
212
+ @click.pass_obj
213
+ def create(root_flags, title, text, items):
214
+ """Create a new note."""
215
+ if not root_flags.account:
216
+ click.echo("Error: --account is required for login.", err=True)
217
+ sys.exit(1)
218
+
219
+ if not text and not items:
220
+ click.echo("Error: provide --text or at least one --item", err=True)
221
+ sys.exit(1)
222
+
223
+ try:
224
+ keep = get_keep(root_flags.account)
225
+ note = create_note(keep, title or "", text=text, items=items)
226
+ if root_flags.output_json:
227
+ click.echo(
228
+ json.dumps(
229
+ {
230
+ "id": note.id,
231
+ "name": f"notes/{note.id}",
232
+ "title": note.title,
233
+ "created": format_date(note.timestamps.created),
234
+ },
235
+ indent=2,
236
+ )
237
+ )
238
+ else:
239
+ click.echo(f"name\tnotes/{note.id}")
240
+ click.echo(f"title\t{note.title}")
241
+ click.echo(f"created\t{format_date(note.timestamps.created)}")
242
+ except Exception as e:
243
+ click.echo(f"Error: {e}", err=True)
244
+ sys.exit(1)
245
+
246
+
247
+ @main.command()
248
+ @click.argument("note_id")
249
+ @click.pass_obj
250
+ def delete(root_flags, note_id):
251
+ """Delete a note."""
252
+ if not root_flags.account:
253
+ click.echo("Error: --account is required.", err=True)
254
+ sys.exit(1)
255
+
256
+ try:
257
+ keep = get_keep(root_flags.account)
258
+ if delete_note(keep, note_id):
259
+ if root_flags.output_json:
260
+ click.echo(json.dumps({"deleted": True, "name": f"notes/{note_id}"}))
261
+ else:
262
+ click.echo("deleted\tTrue")
263
+ click.echo(f"name\tnotes/{note_id}")
264
+ else:
265
+ click.echo(f"Error: Note not found: {note_id}", err=True)
266
+ sys.exit(1)
267
+ except Exception as e:
268
+ click.echo(f"Error: {e}", err=True)
269
+ sys.exit(1)
270
+
271
+
272
+ @main.command()
273
+ @click.argument("attachment_name")
274
+ @click.option("--out", help="Output file path")
275
+ @click.pass_obj
276
+ def attachment(root_flags, attachment_name, out):
277
+ """Download an attachment."""
278
+ if not root_flags.account:
279
+ click.echo("Error: --account is required.", err=True)
280
+ sys.exit(1)
281
+
282
+ out_path = out
283
+ if not out_path:
284
+ parts = attachment_name.split("/")
285
+ out_path = parts[-1]
286
+
287
+ try:
288
+ keep = get_keep(root_flags.account)
289
+ from .keep import download_attachment
290
+
291
+ bytes_written = download_attachment(keep, attachment_name, out_path)
292
+ if bytes_written > 0:
293
+ if root_flags.output_json:
294
+ click.echo(json.dumps({"downloaded": True, "path": out_path, "bytes": bytes_written}))
295
+ else:
296
+ click.echo(f"path\t{out_path}")
297
+ click.echo(f"bytes\t{bytes_written}")
298
+ else:
299
+ click.echo(f"Error: Attachment not found: {attachment_name}", err=True)
300
+ sys.exit(1)
301
+ except Exception as e:
302
+ click.echo(f"Error: {e}", err=True)
303
+ sys.exit(1)
304
+
305
+
306
+ if __name__ == "__main__":
307
+ main()
@@ -0,0 +1,104 @@
1
+ import hashlib
2
+
3
+ import gkeepapi
4
+
5
+ from .auth import get_master_token
6
+
7
+
8
+ def get_keep(email):
9
+ master_token = get_master_token(email)
10
+ if not master_token:
11
+ raise Exception("No master token found. Run 'gogkeep login' first.")
12
+
13
+ # Ensure android_id is consistent with the one used during exchange.
14
+ android_id = hashlib.md5(email.encode()).hexdigest()[:16]
15
+
16
+ keep = gkeepapi.Keep()
17
+
18
+ try:
19
+ # device_id must match the one used to exchange the token.
20
+ keep.authenticate(email, master_token, device_id=android_id)
21
+ return keep
22
+ except gkeepapi.exception.LoginException as e:
23
+ raise Exception(f"Login failed: {e}. You may need to run 'gogkeep login' again.") from e
24
+
25
+
26
+ def list_notes(keep, max_results=100):
27
+ notes = list(keep.find(trashed=False))[:max_results]
28
+ return notes
29
+
30
+
31
+ def search_notes(keep, query):
32
+ q = query.lower()
33
+
34
+ def search_func(n):
35
+ if q in n.title.lower():
36
+ return True
37
+ if hasattr(n, "text") and q in n.text.lower():
38
+ return True
39
+ if hasattr(n, "items"):
40
+ for item in n.items:
41
+ if q in item.text.lower():
42
+ return True
43
+ return False
44
+
45
+ notes = list(keep.find(func=search_func, trashed=False))
46
+ return notes
47
+
48
+
49
+ def get_note(keep, note_id):
50
+ # gkeepapi's get() takes an ID.
51
+ note = keep.get(note_id)
52
+ return note
53
+
54
+
55
+ def create_note(keep, title, text=None, items=None):
56
+ if text:
57
+ note = keep.createNote(title, text)
58
+ elif items:
59
+ note = keep.createList(title, items)
60
+ else:
61
+ note = keep.createNote(title, "")
62
+ keep.sync()
63
+ return note
64
+
65
+
66
+ def delete_note(keep, note_id):
67
+ note = keep.get(note_id)
68
+ if note:
69
+ note.delete()
70
+ keep.sync()
71
+ return True
72
+ return False
73
+
74
+
75
+ def download_attachment(keep, attachment_name, out_path):
76
+ # attachment_name: notes/<noteId>/attachments/<attachmentId>
77
+ parts = attachment_name.split("/")
78
+ if len(parts) < 4:
79
+ raise Exception("Invalid attachment name format. Expected: notes/<noteId>/attachments/<attachmentId>")
80
+ note_id = parts[1]
81
+ attachment_id = parts[3]
82
+
83
+ note = keep.get(note_id)
84
+ if not note:
85
+ raise Exception(f"Note not found: {note_id}")
86
+
87
+ # Search in images, drawings, audio
88
+ # gkeepapi uses different attributes for different types of blobs
89
+ blobs = []
90
+ if hasattr(note, "images"):
91
+ blobs.extend(note.images)
92
+ if hasattr(note, "drawings"):
93
+ blobs.extend(note.drawings)
94
+ if hasattr(note, "audio"):
95
+ blobs.extend(note.audio)
96
+
97
+ for att in blobs:
98
+ if att.id == attachment_id:
99
+ blob = att.get_blob()
100
+ data = blob.read()
101
+ with open(out_path, "wb") as f:
102
+ f.write(data)
103
+ return len(data)
104
+ return 0
@@ -0,0 +1,160 @@
1
+ import concurrent.futures
2
+ import contextlib
3
+ import getpass
4
+ import json
5
+ import os
6
+ import platform
7
+ import sys
8
+
9
+ SERVICE_NAME = "gogkeep"
10
+
11
+ _backend_cache = None
12
+
13
+
14
+ def get_keyring_backend():
15
+ """Resolve and return the appropriate keyring backend based on platform and env vars."""
16
+ global _backend_cache
17
+ if _backend_cache is not None:
18
+ return _backend_cache
19
+
20
+ backend_env = os.environ.get("GOGKEEP_KEYRING_BACKEND", "auto")
21
+
22
+ selected_backend = None
23
+
24
+ # gogcli logic: Force file backend on linux if no DBus session (common on headless/wsl/docker)
25
+ dbus_addr = os.environ.get("DBUS_SESSION_BUS_ADDRESS")
26
+ if platform.system() == "Linux" and backend_env == "auto" and not dbus_addr:
27
+ backend_env = "file"
28
+
29
+ if backend_env == "auto":
30
+ from keyring.backend import get_all_keyring
31
+
32
+ all_backends = sorted(get_all_keyring(), key=lambda x: x.priority, reverse=True)
33
+
34
+ candidate = None
35
+ for be in all_backends:
36
+ if be.priority >= 1 and "keyrings.alt" not in str(be.__class__):
37
+ candidate = be
38
+ break
39
+
40
+ if candidate:
41
+ if platform.system() == "Linux" and "SecretService" in str(candidate.__class__):
42
+
43
+ def check_native():
44
+ with contextlib.suppress(Exception):
45
+ candidate.get_credential("test", "test")
46
+
47
+ with concurrent.futures.ThreadPoolExecutor(max_workers=1) as executor:
48
+ future = executor.submit(check_native)
49
+ try:
50
+ future.result(timeout=5)
51
+ selected_backend = candidate
52
+ except concurrent.futures.TimeoutError:
53
+ backend_env = "file"
54
+ else:
55
+ selected_backend = candidate
56
+ else:
57
+ backend_env = "file"
58
+
59
+ if backend_env == "keychain" and platform.system() == "Darwin":
60
+ from keyring.backends.macOS import Keyring
61
+
62
+ selected_backend = Keyring()
63
+
64
+ # Isolated Encrypted File Backend Fallback
65
+ if backend_env == "file" or selected_backend is None:
66
+ try:
67
+ from keyrings.alt.file import EncryptedKeyring
68
+
69
+ class IsolatedEncryptedKeyring(EncryptedKeyring):
70
+ @property
71
+ def file_path(self):
72
+ custom_dir = os.environ.get("GOGKEEP_KEYRING_PATH", os.path.expanduser("~/.config/gogkeep"))
73
+ os.makedirs(custom_dir, exist_ok=True)
74
+ return os.path.join(custom_dir, "crypted_pass.cfg")
75
+
76
+ def _get_keyring_key(self):
77
+ env_pwd = os.environ.get("GOGKEEP_KEYRING_PASSWORD")
78
+ if env_pwd is not None:
79
+ return env_pwd
80
+
81
+ if not sys.stdin.isatty():
82
+ raise RuntimeError("Not a TTY and GOGKEEP_KEYRING_PASSWORD not set for file backend.")
83
+
84
+ val = getpass.getpass("Password for encrypted keyring: ")
85
+ os.environ["GOGKEEP_KEYRING_PASSWORD"] = val
86
+ return val
87
+
88
+ @property
89
+ def keyring_key(self):
90
+ return self._get_keyring_key()
91
+
92
+ selected_backend = IsolatedEncryptedKeyring()
93
+ except ImportError:
94
+ from keyring.backends.null import Keyring as NullKeyring
95
+
96
+ selected_backend = NullKeyring()
97
+
98
+ _backend_cache = selected_backend
99
+ return selected_backend
100
+
101
+
102
+ def set_token(client: str, email: str, token: str, backend_override=None):
103
+ kr = backend_override or get_keyring_backend()
104
+ key = f"token:{client}:{email}"
105
+ kr.set_password(SERVICE_NAME, key, token)
106
+
107
+ if platform.system() == "Darwin" and "macOS" in str(kr.__class__):
108
+ validated = kr.get_password(SERVICE_NAME, key)
109
+ if not validated:
110
+ kr.set_password(SERVICE_NAME, key, token)
111
+
112
+
113
+ def get_token(client: str, email: str, backend_override=None) -> str | None:
114
+ kr = backend_override or get_keyring_backend()
115
+ primary_key = f"token:{client}:{email}"
116
+ legacy_key = f"token:{email}"
117
+
118
+ with contextlib.redirect_stderr(open(os.devnull, "w")):
119
+ try:
120
+ data = kr.get_password(SERVICE_NAME, primary_key)
121
+ except Exception:
122
+ data = None
123
+
124
+ if data is None and client == "default":
125
+ try:
126
+ data = kr.get_password(SERVICE_NAME, legacy_key)
127
+ if data is not None:
128
+ kr.set_password(SERVICE_NAME, primary_key, data)
129
+ except Exception:
130
+ data = None
131
+
132
+ if data is None:
133
+ raise KeyError("Token not found")
134
+
135
+ if isinstance(data, str) and data.startswith("{"):
136
+ try:
137
+ return json.loads(data).get("master_token", data)
138
+ except json.JSONDecodeError:
139
+ pass
140
+
141
+ return data
142
+
143
+
144
+ def get_default_account(client: str = "default", backend_override=None) -> str | None:
145
+ kr = backend_override or get_keyring_backend()
146
+ with contextlib.redirect_stderr(open(os.devnull, "w")):
147
+ try:
148
+ account = kr.get_password(SERVICE_NAME, f"default_account:{client}")
149
+ if not account:
150
+ account = kr.get_password(SERVICE_NAME, "default_account")
151
+ if not account:
152
+ account = kr.get_password("gogkeep", "last_used")
153
+ except Exception:
154
+ return None
155
+ return account
156
+
157
+
158
+ def set_default_account(email: str, client: str = "default", backend_override=None):
159
+ kr = backend_override or get_keyring_backend()
160
+ kr.set_password(SERVICE_NAME, f"default_account:{client}", email)
@@ -0,0 +1,17 @@
1
+ Metadata-Version: 2.4
2
+ Name: gogkeep
3
+ Version: 0.1.0
4
+ Summary: CLI interface for Google Keep matching gog style
5
+ Author-email: John <john@example.com>
6
+ Requires-Python: >=3.12
7
+ License-File: LICENSE
8
+ Requires-Dist: gkeepapi
9
+ Requires-Dist: gpsoauth
10
+ Requires-Dist: click
11
+ Requires-Dist: tabulate
12
+ Requires-Dist: keyring
13
+ Requires-Dist: keyrings.alt
14
+ Requires-Dist: keyring-pass
15
+ Requires-Dist: cryptography
16
+ Requires-Dist: websockets>=16.0
17
+ Dynamic: license-file
@@ -0,0 +1,17 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ src/gogkeep/__init__.py
5
+ src/gogkeep/__main__.py
6
+ src/gogkeep/auth.py
7
+ src/gogkeep/cdp_auth.py
8
+ src/gogkeep/cli.py
9
+ src/gogkeep/keep.py
10
+ src/gogkeep/store.py
11
+ src/gogkeep.egg-info/PKG-INFO
12
+ src/gogkeep.egg-info/SOURCES.txt
13
+ src/gogkeep.egg-info/dependency_links.txt
14
+ src/gogkeep.egg-info/entry_points.txt
15
+ src/gogkeep.egg-info/requires.txt
16
+ src/gogkeep.egg-info/top_level.txt
17
+ tests/test_cli.py
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ gogkeep = gogkeep.cli:main
@@ -0,0 +1,9 @@
1
+ gkeepapi
2
+ gpsoauth
3
+ click
4
+ tabulate
5
+ keyring
6
+ keyrings.alt
7
+ keyring-pass
8
+ cryptography
9
+ websockets>=16.0
@@ -0,0 +1 @@
1
+ gogkeep
@@ -0,0 +1,80 @@
1
+ import unittest
2
+ from unittest.mock import MagicMock, patch
3
+
4
+ from click.testing import CliRunner
5
+
6
+ from gogkeep.cli import main
7
+
8
+
9
+ class TestCLI(unittest.TestCase):
10
+ def setUp(self):
11
+ self.runner = CliRunner()
12
+
13
+ @patch("gogkeep.cli.get_keep")
14
+ @patch("gogkeep.cli.list_notes")
15
+ def test_list_command(self, mock_list_notes, mock_get_keep):
16
+ # Mock note object
17
+ mock_note = MagicMock()
18
+ mock_note.id = "abc123"
19
+ mock_note.title = "Test Note"
20
+ mock_note.timestamps.updated.isoformat.return_value = "2024-01-01T00:00:00Z"
21
+ mock_note.trashed = False
22
+
23
+ mock_list_notes.return_value = [mock_note]
24
+
25
+ result = self.runner.invoke(main, ["-a", "test@example.com", "list"])
26
+
27
+ self.assertEqual(result.exit_code, 0)
28
+ self.assertIn("NAME", result.output)
29
+ self.assertIn("TITLE", result.output)
30
+ self.assertIn("UPDATED", result.output)
31
+ self.assertIn("notes/abc123", result.output)
32
+ self.assertIn("Test Note", result.output)
33
+
34
+ @patch("gogkeep.cli.get_keep")
35
+ @patch("gogkeep.cli.get_note")
36
+ def test_get_command(self, mock_get_note, mock_get_keep):
37
+ # Mock note object
38
+ mock_note = MagicMock()
39
+ mock_note.id = "abc123"
40
+ mock_note.title = "Test Note"
41
+ mock_note.timestamps.created.isoformat.return_value = "2024-01-01T00:00:00Z"
42
+ mock_note.timestamps.updated.isoformat.return_value = "2024-01-02T00:00:00Z"
43
+ mock_note.text = "Hello World"
44
+ mock_note.trashed = False
45
+ mock_note.images = []
46
+ mock_note.drawings = []
47
+ mock_note.audio = []
48
+
49
+ mock_get_note.return_value = mock_note
50
+
51
+ result = self.runner.invoke(main, ["-a", "test@example.com", "get", "abc123"])
52
+
53
+ self.assertEqual(result.exit_code, 0)
54
+ self.assertIn("name\tnotes/abc123", result.output)
55
+ self.assertIn("title\tTest Note", result.output)
56
+ self.assertIn("Hello World", result.output)
57
+
58
+ @patch("gogkeep.cli.get_keep")
59
+ @patch("gogkeep.cli.create_note")
60
+ def test_create_command(self, mock_create_note, mock_get_keep):
61
+ # Mock note object
62
+ mock_note = MagicMock()
63
+ mock_note.id = "new123"
64
+ mock_note.title = "New Note"
65
+ mock_note.timestamps.created.isoformat.return_value = "2024-01-01T00:00:00Z"
66
+
67
+ mock_create_note.return_value = mock_note
68
+
69
+ result = self.runner.invoke(
70
+ main, ["-a", "test@example.com", "create", "--title", "New Note", "--text", "content"]
71
+ )
72
+
73
+ self.assertEqual(result.exit_code, 0)
74
+ self.assertIn("name\tnotes/new123", result.output)
75
+ self.assertIn("title\tNew Note", result.output)
76
+ self.assertIn("created\t2024-01-01T00:00:00Z", result.output)
77
+
78
+
79
+ if __name__ == "__main__":
80
+ unittest.main()