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 +21 -0
- gogkeep-0.1.0/PKG-INFO +17 -0
- gogkeep-0.1.0/README.md +72 -0
- gogkeep-0.1.0/pyproject.toml +40 -0
- gogkeep-0.1.0/setup.cfg +4 -0
- gogkeep-0.1.0/src/gogkeep/__init__.py +1 -0
- gogkeep-0.1.0/src/gogkeep/__main__.py +4 -0
- gogkeep-0.1.0/src/gogkeep/auth.py +75 -0
- gogkeep-0.1.0/src/gogkeep/cdp_auth.py +199 -0
- gogkeep-0.1.0/src/gogkeep/cli.py +307 -0
- gogkeep-0.1.0/src/gogkeep/keep.py +104 -0
- gogkeep-0.1.0/src/gogkeep/store.py +160 -0
- gogkeep-0.1.0/src/gogkeep.egg-info/PKG-INFO +17 -0
- gogkeep-0.1.0/src/gogkeep.egg-info/SOURCES.txt +17 -0
- gogkeep-0.1.0/src/gogkeep.egg-info/dependency_links.txt +1 -0
- gogkeep-0.1.0/src/gogkeep.egg-info/entry_points.txt +2 -0
- gogkeep-0.1.0/src/gogkeep.egg-info/requires.txt +9 -0
- gogkeep-0.1.0/src/gogkeep.egg-info/top_level.txt +1 -0
- gogkeep-0.1.0/tests/test_cli.py +80 -0
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
|
gogkeep-0.1.0/README.md
ADDED
|
@@ -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
|
+
]
|
gogkeep-0.1.0/setup.cfg
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# gogkeep package
|
|
@@ -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 @@
|
|
|
1
|
+
|
|
@@ -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()
|