alexa-sync 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.
- alexa_sync-0.1.0/LICENSE +21 -0
- alexa_sync-0.1.0/PKG-INFO +85 -0
- alexa_sync-0.1.0/README.md +63 -0
- alexa_sync-0.1.0/pyproject.toml +46 -0
- alexa_sync-0.1.0/pyproject.toml.orig +40 -0
- alexa_sync-0.1.0/src/alexa_sync/__init__.py +3 -0
- alexa_sync-0.1.0/src/alexa_sync/amazon.py +211 -0
- alexa_sync-0.1.0/src/alexa_sync/cli.py +289 -0
- alexa_sync-0.1.0/src/alexa_sync/reminders.py +101 -0
- alexa_sync-0.1.0/src/alexa_sync/sync.py +189 -0
alexa_sync-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Owen Williams
|
|
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.
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: alexa-sync
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Two-way sync between the Alexa shopping list and Apple Reminders
|
|
5
|
+
Keywords: alexa,amazon,shopping list,apple reminders,macos,sync
|
|
6
|
+
Author: Owen Williams
|
|
7
|
+
Author-email: Owen Williams <owen@willia.ms>
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: Environment :: Console
|
|
12
|
+
Classifier: Environment :: MacOS X
|
|
13
|
+
Classifier: Operating System :: MacOS
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Topic :: Utilities
|
|
16
|
+
Requires-Dist: aioamazondevices==16.3.0
|
|
17
|
+
Requires-Dist: keyring>=25.7.0
|
|
18
|
+
Requires-Python: >=3.13
|
|
19
|
+
Project-URL: Homepage, https://github.com/ow/Alexa-apple-reminders
|
|
20
|
+
Project-URL: Issues, https://github.com/ow/Alexa-apple-reminders/issues
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
|
|
23
|
+
# alexa-sync
|
|
24
|
+
|
|
25
|
+
Two-way sync between your Alexa shopping list and a list in Apple Reminders, on macOS.
|
|
26
|
+
|
|
27
|
+
Say "Alexa, add oat milk to my shopping list" and it shows up in Reminders a minute or two later. Tick it off in Reminders at the store and it's ticked off on Alexa too.
|
|
28
|
+
|
|
29
|
+
> **Unofficial.** Amazon has no public API for the shopping list anymore, so this uses the same private endpoints as the Alexa app. It can break whenever Amazon changes them. Use at your own risk.
|
|
30
|
+
|
|
31
|
+
## Requirements
|
|
32
|
+
|
|
33
|
+
- macOS 14+
|
|
34
|
+
- [uv](https://docs.astral.sh/uv/) (or pipx)
|
|
35
|
+
- [`remindctl`](https://github.com/openclaw/remindctl): `brew install steipete/tap/remindctl`, then `remindctl authorize`
|
|
36
|
+
- An Amazon account with **2-Step Verification using an authenticator app** (the login flow needs a code, not an SMS or push approval)
|
|
37
|
+
|
|
38
|
+
## Setup
|
|
39
|
+
|
|
40
|
+
```sh
|
|
41
|
+
uv tool install git+https://github.com/ow/Alexa-apple-reminders
|
|
42
|
+
|
|
43
|
+
alexa-sync login # email, password, authenticator code (one time)
|
|
44
|
+
alexa-sync doctor --list Groceries # check everything is ready
|
|
45
|
+
alexa-sync run --list Groceries --dry-run # preview the first sync
|
|
46
|
+
alexa-sync install --list Groceries # sync every 2 minutes in the background
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
The Reminders list must already exist. `--list` defaults to `Groceries`.
|
|
50
|
+
|
|
51
|
+
Other commands: `alexa-sync run` (sync once), `alexa-sync uninstall` (stop the background job), `install --interval 60` (change how often it runs, in seconds).
|
|
52
|
+
|
|
53
|
+
## How it works
|
|
54
|
+
|
|
55
|
+
**Amazon login.** `login` registers your Mac as a device on your Amazon account, the same way the Alexa phone app does (via [aioamazondevices](https://github.com/chemelli74/aioamazondevices)). That returns a long-lived token, which is kept in your macOS Keychain. Your password and 2FA code are not stored. The token is used to get fresh session cookies whenever needed. If it ever stops working you'll get a macOS notification asking you to run `alexa-sync login` again. To revoke it, remove the device under *Manage Your Content and Devices* on Amazon.
|
|
56
|
+
|
|
57
|
+
**Sync.** Each run reads both lists and compares them with what they looked like after the last sync, so it can tell which side changed:
|
|
58
|
+
|
|
59
|
+
- Adds, check-offs, un-checks, renames and deletes go both ways.
|
|
60
|
+
- If the same item changed on both sides between syncs, Alexa wins.
|
|
61
|
+
- Items that are already completed when first seen aren't copied. This keeps years of old Alexa history out of Reminders.
|
|
62
|
+
- On the first sync, items with the same name on both sides are paired rather than duplicated.
|
|
63
|
+
- If a single pass would delete more than 5 items it stops and notifies you instead (override with `--max-deletes`).
|
|
64
|
+
|
|
65
|
+
**Files.**
|
|
66
|
+
|
|
67
|
+
| What | Where |
|
|
68
|
+
| --- | --- |
|
|
69
|
+
| Amazon device token | Keychain, service `alexa-sync` |
|
|
70
|
+
| Sync state | `~/Library/Application Support/alexa-sync/state.json` |
|
|
71
|
+
| Log | `~/Library/Logs/alexa-sync.log` |
|
|
72
|
+
| Background job | `~/Library/LaunchAgents/io.github.alexa-sync.plist` |
|
|
73
|
+
|
|
74
|
+
Only the default Alexa shopping list is synced. It has been tested with amazon.com; other Amazon regions should work, since the login library supports them, but are untested.
|
|
75
|
+
|
|
76
|
+
## Development
|
|
77
|
+
|
|
78
|
+
```sh
|
|
79
|
+
uv run pytest
|
|
80
|
+
uv run alexa-sync run --dry-run -v
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
## License
|
|
84
|
+
|
|
85
|
+
MIT
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# alexa-sync
|
|
2
|
+
|
|
3
|
+
Two-way sync between your Alexa shopping list and a list in Apple Reminders, on macOS.
|
|
4
|
+
|
|
5
|
+
Say "Alexa, add oat milk to my shopping list" and it shows up in Reminders a minute or two later. Tick it off in Reminders at the store and it's ticked off on Alexa too.
|
|
6
|
+
|
|
7
|
+
> **Unofficial.** Amazon has no public API for the shopping list anymore, so this uses the same private endpoints as the Alexa app. It can break whenever Amazon changes them. Use at your own risk.
|
|
8
|
+
|
|
9
|
+
## Requirements
|
|
10
|
+
|
|
11
|
+
- macOS 14+
|
|
12
|
+
- [uv](https://docs.astral.sh/uv/) (or pipx)
|
|
13
|
+
- [`remindctl`](https://github.com/openclaw/remindctl): `brew install steipete/tap/remindctl`, then `remindctl authorize`
|
|
14
|
+
- An Amazon account with **2-Step Verification using an authenticator app** (the login flow needs a code, not an SMS or push approval)
|
|
15
|
+
|
|
16
|
+
## Setup
|
|
17
|
+
|
|
18
|
+
```sh
|
|
19
|
+
uv tool install git+https://github.com/ow/Alexa-apple-reminders
|
|
20
|
+
|
|
21
|
+
alexa-sync login # email, password, authenticator code (one time)
|
|
22
|
+
alexa-sync doctor --list Groceries # check everything is ready
|
|
23
|
+
alexa-sync run --list Groceries --dry-run # preview the first sync
|
|
24
|
+
alexa-sync install --list Groceries # sync every 2 minutes in the background
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
The Reminders list must already exist. `--list` defaults to `Groceries`.
|
|
28
|
+
|
|
29
|
+
Other commands: `alexa-sync run` (sync once), `alexa-sync uninstall` (stop the background job), `install --interval 60` (change how often it runs, in seconds).
|
|
30
|
+
|
|
31
|
+
## How it works
|
|
32
|
+
|
|
33
|
+
**Amazon login.** `login` registers your Mac as a device on your Amazon account, the same way the Alexa phone app does (via [aioamazondevices](https://github.com/chemelli74/aioamazondevices)). That returns a long-lived token, which is kept in your macOS Keychain. Your password and 2FA code are not stored. The token is used to get fresh session cookies whenever needed. If it ever stops working you'll get a macOS notification asking you to run `alexa-sync login` again. To revoke it, remove the device under *Manage Your Content and Devices* on Amazon.
|
|
34
|
+
|
|
35
|
+
**Sync.** Each run reads both lists and compares them with what they looked like after the last sync, so it can tell which side changed:
|
|
36
|
+
|
|
37
|
+
- Adds, check-offs, un-checks, renames and deletes go both ways.
|
|
38
|
+
- If the same item changed on both sides between syncs, Alexa wins.
|
|
39
|
+
- Items that are already completed when first seen aren't copied. This keeps years of old Alexa history out of Reminders.
|
|
40
|
+
- On the first sync, items with the same name on both sides are paired rather than duplicated.
|
|
41
|
+
- If a single pass would delete more than 5 items it stops and notifies you instead (override with `--max-deletes`).
|
|
42
|
+
|
|
43
|
+
**Files.**
|
|
44
|
+
|
|
45
|
+
| What | Where |
|
|
46
|
+
| --- | --- |
|
|
47
|
+
| Amazon device token | Keychain, service `alexa-sync` |
|
|
48
|
+
| Sync state | `~/Library/Application Support/alexa-sync/state.json` |
|
|
49
|
+
| Log | `~/Library/Logs/alexa-sync.log` |
|
|
50
|
+
| Background job | `~/Library/LaunchAgents/io.github.alexa-sync.plist` |
|
|
51
|
+
|
|
52
|
+
Only the default Alexa shopping list is synced. It has been tested with amazon.com; other Amazon regions should work, since the login library supports them, but are untested.
|
|
53
|
+
|
|
54
|
+
## Development
|
|
55
|
+
|
|
56
|
+
```sh
|
|
57
|
+
uv run pytest
|
|
58
|
+
uv run alexa-sync run --dry-run -v
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
## License
|
|
62
|
+
|
|
63
|
+
MIT
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "alexa-sync"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Two-way sync between the Alexa shopping list and Apple Reminders"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.13"
|
|
7
|
+
license = "MIT"
|
|
8
|
+
license-files = ["LICENSE"]
|
|
9
|
+
keywords = [
|
|
10
|
+
"alexa",
|
|
11
|
+
"amazon",
|
|
12
|
+
"shopping list",
|
|
13
|
+
"apple reminders",
|
|
14
|
+
"macos",
|
|
15
|
+
"sync",
|
|
16
|
+
]
|
|
17
|
+
classifiers = [
|
|
18
|
+
"Development Status :: 4 - Beta",
|
|
19
|
+
"Environment :: Console",
|
|
20
|
+
"Environment :: MacOS X",
|
|
21
|
+
"Operating System :: MacOS",
|
|
22
|
+
"Programming Language :: Python :: 3",
|
|
23
|
+
"Topic :: Utilities",
|
|
24
|
+
]
|
|
25
|
+
dependencies = [
|
|
26
|
+
"aioamazondevices==16.3.0",
|
|
27
|
+
"keyring>=25.7.0",
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
[[project.authors]]
|
|
31
|
+
name = "Owen Williams"
|
|
32
|
+
email = "owen@willia.ms"
|
|
33
|
+
|
|
34
|
+
[project.urls]
|
|
35
|
+
Homepage = "https://github.com/ow/Alexa-apple-reminders"
|
|
36
|
+
Issues = "https://github.com/ow/Alexa-apple-reminders/issues"
|
|
37
|
+
|
|
38
|
+
[project.scripts]
|
|
39
|
+
alexa-sync = "alexa_sync.cli:main"
|
|
40
|
+
|
|
41
|
+
[build-system]
|
|
42
|
+
requires = ["uv_build>=0.10.3,<0.11.0"]
|
|
43
|
+
build-backend = "uv_build"
|
|
44
|
+
|
|
45
|
+
[dependency-groups]
|
|
46
|
+
dev = ["pytest>=9.1.1"]
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "alexa-sync"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Two-way sync between the Alexa shopping list and Apple Reminders"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
authors = [
|
|
7
|
+
{ name = "Owen Williams", email = "owen@willia.ms" }
|
|
8
|
+
]
|
|
9
|
+
requires-python = ">=3.13"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
license-files = ["LICENSE"]
|
|
12
|
+
keywords = ["alexa", "amazon", "shopping list", "apple reminders", "macos", "sync"]
|
|
13
|
+
classifiers = [
|
|
14
|
+
"Development Status :: 4 - Beta",
|
|
15
|
+
"Environment :: Console",
|
|
16
|
+
"Environment :: MacOS X",
|
|
17
|
+
"Operating System :: MacOS",
|
|
18
|
+
"Programming Language :: Python :: 3",
|
|
19
|
+
"Topic :: Utilities",
|
|
20
|
+
]
|
|
21
|
+
dependencies = [
|
|
22
|
+
"aioamazondevices==16.3.0",
|
|
23
|
+
"keyring>=25.7.0",
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
[project.urls]
|
|
27
|
+
Homepage = "https://github.com/ow/Alexa-apple-reminders"
|
|
28
|
+
Issues = "https://github.com/ow/Alexa-apple-reminders/issues"
|
|
29
|
+
|
|
30
|
+
[project.scripts]
|
|
31
|
+
alexa-sync = "alexa_sync.cli:main"
|
|
32
|
+
|
|
33
|
+
[build-system]
|
|
34
|
+
requires = ["uv_build>=0.10.3,<0.11.0"]
|
|
35
|
+
build-backend = "uv_build"
|
|
36
|
+
|
|
37
|
+
[dependency-groups]
|
|
38
|
+
dev = [
|
|
39
|
+
"pytest>=9.1.1",
|
|
40
|
+
]
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
"""Alexa shopping list side of the sync.
|
|
2
|
+
|
|
3
|
+
Authentication piggybacks on aioamazondevices (the library behind Home
|
|
4
|
+
Assistant's Alexa integration): it registers a virtual Alexa-app device once,
|
|
5
|
+
which yields a long-lived refresh token. From then on we only ever trade that
|
|
6
|
+
token for fresh website cookies; the password and OTP are never stored.
|
|
7
|
+
|
|
8
|
+
The list endpoints are the same undocumented /alexashoppinglists/api/v2 calls
|
|
9
|
+
the library and pyalexatodo use. We call them ourselves so we can paginate and
|
|
10
|
+
see every field, but go through the library's HTTP wrapper for cookies/CSRF.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import logging
|
|
16
|
+
from collections.abc import Callable, Coroutine
|
|
17
|
+
from http import HTTPMethod
|
|
18
|
+
from typing import Any
|
|
19
|
+
|
|
20
|
+
from aiohttp import ClientSession
|
|
21
|
+
from aioamazondevices.api import AmazonEchoApi
|
|
22
|
+
from aioamazondevices.exceptions import CannotAuthenticate
|
|
23
|
+
from aioamazondevices.structures import AmazonSaveDataConfig
|
|
24
|
+
from yarl import URL
|
|
25
|
+
|
|
26
|
+
from .sync import Item, normalize
|
|
27
|
+
|
|
28
|
+
log = logging.getLogger(__name__)
|
|
29
|
+
|
|
30
|
+
LISTS_PATH = "alexashoppinglists/api/v2/lists"
|
|
31
|
+
PAGE_SIZE = 100 # API maximum
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class AuthExpired(Exception):
|
|
35
|
+
"""The stored device registration no longer works; run `alexa-sync login`."""
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def make_api(session: ClientSession, email: str, password: str, login_data: dict | None, data_dir: str) -> AmazonEchoApi:
|
|
39
|
+
return AmazonEchoApi(
|
|
40
|
+
client_session=session,
|
|
41
|
+
login_email=email,
|
|
42
|
+
login_password=password,
|
|
43
|
+
login_data=login_data,
|
|
44
|
+
save_data=AmazonSaveDataConfig(path=data_dir),
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class AlexaClient:
|
|
49
|
+
"""Async client for the shopping list. Refreshes cookies once on auth failure."""
|
|
50
|
+
|
|
51
|
+
def __init__(self, api: AmazonEchoApi) -> None:
|
|
52
|
+
self.api = api
|
|
53
|
+
self.login_data_changed = False
|
|
54
|
+
|
|
55
|
+
@property
|
|
56
|
+
def login_data(self) -> dict[str, Any]:
|
|
57
|
+
return self.api.login._session_state_data.login_stored_data
|
|
58
|
+
|
|
59
|
+
async def connect(self) -> None:
|
|
60
|
+
await self._with_refresh(self.api.login.login_mode_stored_data)
|
|
61
|
+
|
|
62
|
+
async def _refresh_cookies(self) -> None:
|
|
63
|
+
log.info("session cookies rejected; exchanging refresh token for new ones")
|
|
64
|
+
try:
|
|
65
|
+
await self.api.login._refresh_auth_cookies()
|
|
66
|
+
except Exception as exc:
|
|
67
|
+
raise AuthExpired("could not refresh Amazon cookies") from exc
|
|
68
|
+
self.login_data_changed = True
|
|
69
|
+
|
|
70
|
+
async def _with_refresh(self, fn: Callable[[], Coroutine[Any, Any, Any]]) -> Any:
|
|
71
|
+
try:
|
|
72
|
+
return await fn()
|
|
73
|
+
except (CannotAuthenticate, ValueError) as exc:
|
|
74
|
+
# ValueError: we got HTML (a sign-in page) instead of JSON.
|
|
75
|
+
log.debug("request failed with %r", exc)
|
|
76
|
+
await self._refresh_cookies()
|
|
77
|
+
try:
|
|
78
|
+
return await fn()
|
|
79
|
+
except (CannotAuthenticate, ValueError) as exc:
|
|
80
|
+
raise AuthExpired("Amazon rejected freshly refreshed cookies") from exc
|
|
81
|
+
|
|
82
|
+
async def _call(self, method: HTTPMethod, path: str, query: dict | None = None, body: dict | None = None) -> dict:
|
|
83
|
+
wrapper = self.api._http_wrapper
|
|
84
|
+
url = URL.joinpath(self.api.login._session_state_data.retail_site_url, LISTS_PATH, path)
|
|
85
|
+
if query:
|
|
86
|
+
url = url.with_query(query)
|
|
87
|
+
|
|
88
|
+
async def go() -> dict:
|
|
89
|
+
_, resp = await wrapper.session_request(method=method, url=url, input_data=body or {}, json_data=True)
|
|
90
|
+
return await wrapper.response_to_json(resp, "")
|
|
91
|
+
|
|
92
|
+
return await self._with_refresh(go)
|
|
93
|
+
|
|
94
|
+
async def shopping_list_id(self) -> str:
|
|
95
|
+
data = await self._call(HTTPMethod.POST, "fetch")
|
|
96
|
+
lists = data["listInfoList"]
|
|
97
|
+
shop = [l for l in lists if l.get("listType") == "SHOP"]
|
|
98
|
+
if not shop:
|
|
99
|
+
raise RuntimeError(f"no SHOP list among {[l.get('listType') for l in lists]}")
|
|
100
|
+
return (next((l for l in shop if l.get("defaultList")), shop[0]))["listId"]
|
|
101
|
+
|
|
102
|
+
async def items(self, list_id: str) -> list[dict]:
|
|
103
|
+
out: list[dict] = []
|
|
104
|
+
token = None
|
|
105
|
+
while True:
|
|
106
|
+
data = await self._call(
|
|
107
|
+
HTTPMethod.POST, f"{list_id}/items/fetch", {"limit": PAGE_SIZE}, {"nextToken": token} if token else {}
|
|
108
|
+
)
|
|
109
|
+
out += data.get("itemInfoList", [])
|
|
110
|
+
token = data.get("nextToken")
|
|
111
|
+
if not token:
|
|
112
|
+
return out
|
|
113
|
+
|
|
114
|
+
async def add(self, list_id: str, name: str) -> dict:
|
|
115
|
+
return await self._call(
|
|
116
|
+
HTTPMethod.POST, f"{list_id}/items", body={"items": [{"itemType": "KEYWORD", "itemName": name}]}
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
async def update(self, list_id: str, item_id: str, version: int, attrs: list[dict]) -> dict:
|
|
120
|
+
return await self._call(
|
|
121
|
+
HTTPMethod.PUT,
|
|
122
|
+
f"{list_id}/items/{item_id}",
|
|
123
|
+
{"version": version},
|
|
124
|
+
{"itemAttributesToUpdate": attrs, "itemAttributesToRemove": []},
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
async def delete(self, list_id: str, item_id: str, version: int) -> None:
|
|
128
|
+
await self._call(HTTPMethod.DELETE, f"{list_id}/items/{item_id}", {"version": version})
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _find_item_id(obj: Any, name: str) -> str | None:
|
|
132
|
+
"""Dig the new item's id out of an add response, whatever its exact shape."""
|
|
133
|
+
if isinstance(obj, dict):
|
|
134
|
+
if "itemId" in obj and normalize(str(obj.get("itemName", name))) == normalize(name):
|
|
135
|
+
return obj["itemId"]
|
|
136
|
+
obj = list(obj.values())
|
|
137
|
+
if isinstance(obj, list):
|
|
138
|
+
for v in obj:
|
|
139
|
+
if found := _find_item_id(v, name):
|
|
140
|
+
return found
|
|
141
|
+
return None
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
class AlexaSide:
|
|
145
|
+
"""Synchronous adapter over AlexaClient for the sync engine."""
|
|
146
|
+
|
|
147
|
+
label = "Alexa"
|
|
148
|
+
|
|
149
|
+
def __init__(self, client: AlexaClient, run: Callable[[Coroutine], Any], list_id: str) -> None:
|
|
150
|
+
self.client = client
|
|
151
|
+
self.run = run
|
|
152
|
+
self.list_id = list_id
|
|
153
|
+
self._versions: dict[str, int] = {}
|
|
154
|
+
|
|
155
|
+
def fetch(self) -> dict[str, Item]:
|
|
156
|
+
rows = self.run(self.client.items(self.list_id))
|
|
157
|
+
self._versions = {r["itemId"]: r["version"] for r in rows}
|
|
158
|
+
return {r["itemId"]: Item(r["itemId"], r["itemName"], r["itemStatus"] == "COMPLETE") for r in rows}
|
|
159
|
+
|
|
160
|
+
def add(self, name: str, completed: bool) -> str:
|
|
161
|
+
before = set(self._versions)
|
|
162
|
+
resp = self.run(self.client.add(self.list_id, name))
|
|
163
|
+
item_id = _find_item_id(resp, name)
|
|
164
|
+
if item_id is None:
|
|
165
|
+
log.debug("add response had no itemId (%r); re-fetching to find it", resp)
|
|
166
|
+
fresh = self.fetch()
|
|
167
|
+
new = [i for i in fresh.values() if i.id not in before and normalize(i.name) == normalize(name)]
|
|
168
|
+
if not new:
|
|
169
|
+
raise RuntimeError(f"added {name!r} but could not find it afterwards")
|
|
170
|
+
item_id = new[0].id
|
|
171
|
+
if completed:
|
|
172
|
+
self.update(item_id, name=None, completed=True)
|
|
173
|
+
return item_id
|
|
174
|
+
|
|
175
|
+
def _version(self, item_id: str) -> int:
|
|
176
|
+
if item_id not in self._versions:
|
|
177
|
+
self.fetch()
|
|
178
|
+
return self._versions[item_id]
|
|
179
|
+
|
|
180
|
+
def update(self, item_id: str, *, name: str | None, completed: bool | None) -> None:
|
|
181
|
+
attrs = []
|
|
182
|
+
if name is not None:
|
|
183
|
+
attrs.append({"type": "itemName", "value": name})
|
|
184
|
+
if completed is not None:
|
|
185
|
+
attrs.append({"type": "itemStatus", "value": "COMPLETE" if completed else "ACTIVE"})
|
|
186
|
+
self.run(self.client.update(self.list_id, item_id, self._version(item_id), attrs))
|
|
187
|
+
self._versions.pop(item_id, None) # version bumped; re-read if needed again
|
|
188
|
+
|
|
189
|
+
def delete(self, item_id: str) -> None:
|
|
190
|
+
self.run(self.client.delete(self.list_id, item_id, self._version(item_id)))
|
|
191
|
+
self._versions.pop(item_id, None)
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
class LoginFailed(Exception):
|
|
195
|
+
pass
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
async def interactive_login(email: str, password: str, otp: str, data_dir: str) -> dict[str, Any]:
|
|
199
|
+
async with ClientSession() as session:
|
|
200
|
+
api = make_api(session, email, password, None, data_dir)
|
|
201
|
+
try:
|
|
202
|
+
return await api.login.login_mode_interactive(otp)
|
|
203
|
+
except CannotAuthenticate as exc:
|
|
204
|
+
if "OTP code not found" in str(exc):
|
|
205
|
+
raise LoginFailed(
|
|
206
|
+
"Amazon didn't ask for a 2-Step Verification code. alexa-sync requires 2SV with an "
|
|
207
|
+
"authenticator app: enable it under Your Account > Login & security, then try again. "
|
|
208
|
+
"(It can also mean the password was wrong or Amazon showed a CAPTCHA.)"
|
|
209
|
+
) from exc
|
|
210
|
+
raise LoginFailed(f"Amazon rejected the login ({exc}). Check the password and code, then retry.") from exc
|
|
211
|
+
|
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
"""alexa-sync: keep the Alexa shopping list and an Apple Reminders list in sync."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import asyncio
|
|
7
|
+
import fcntl
|
|
8
|
+
import getpass
|
|
9
|
+
import json
|
|
10
|
+
import logging
|
|
11
|
+
import os
|
|
12
|
+
import plistlib
|
|
13
|
+
import shutil
|
|
14
|
+
import subprocess
|
|
15
|
+
import sys
|
|
16
|
+
import time
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
import keyring
|
|
20
|
+
|
|
21
|
+
from . import __version__, amazon
|
|
22
|
+
from .reminders import RemindersSide, RemindersUnavailable, check_access
|
|
23
|
+
from .sync import Pair, TooManyDeletes, sync
|
|
24
|
+
|
|
25
|
+
log = logging.getLogger("alexa_sync")
|
|
26
|
+
|
|
27
|
+
DATA_DIR = Path.home() / "Library/Application Support/alexa-sync"
|
|
28
|
+
STATE_FILE = DATA_DIR / "state.json"
|
|
29
|
+
LOCK_FILE = DATA_DIR / "run.lock"
|
|
30
|
+
LOG_FILE = Path.home() / "Library/Logs/alexa-sync.log"
|
|
31
|
+
KEYCHAIN_SERVICE = "alexa-sync"
|
|
32
|
+
KEYCHAIN_ACCOUNT = "amazon-device"
|
|
33
|
+
AGENT_LABEL = "io.github.alexa-sync"
|
|
34
|
+
LEGACY_AGENT_LABELS = ["ms.willia.alexa-sync"]
|
|
35
|
+
LAUNCH_AGENTS = Path.home() / "Library/LaunchAgents"
|
|
36
|
+
AGENT_PLIST = LAUNCH_AGENTS / f"{AGENT_LABEL}.plist"
|
|
37
|
+
ALERT_EVERY = 6 * 3600
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
# --- persistence -----------------------------------------------------------
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def load_credentials() -> dict | None:
|
|
44
|
+
raw = keyring.get_password(KEYCHAIN_SERVICE, KEYCHAIN_ACCOUNT)
|
|
45
|
+
return json.loads(raw) if raw else None
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def save_credentials(email: str, login_data: dict) -> None:
|
|
49
|
+
keyring.set_password(KEYCHAIN_SERVICE, KEYCHAIN_ACCOUNT, json.dumps({"email": email, "login_data": login_data}))
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def load_state() -> dict:
|
|
53
|
+
try:
|
|
54
|
+
return json.loads(STATE_FILE.read_text())
|
|
55
|
+
except FileNotFoundError:
|
|
56
|
+
return {}
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def save_state(state: dict) -> None:
|
|
60
|
+
tmp = STATE_FILE.with_suffix(".tmp")
|
|
61
|
+
tmp.write_text(json.dumps(state, indent=2))
|
|
62
|
+
tmp.replace(STATE_FILE)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def alert(state: dict, key: str, message: str) -> None:
|
|
66
|
+
"""macOS notification, at most once per ALERT_EVERY for the same problem."""
|
|
67
|
+
log.error(message)
|
|
68
|
+
alerts = state.setdefault("alerts", {})
|
|
69
|
+
if time.time() - alerts.get(key, 0) < ALERT_EVERY:
|
|
70
|
+
return
|
|
71
|
+
alerts[key] = time.time()
|
|
72
|
+
script = f"display notification {json.dumps(message)} with title \"Alexa sync\""
|
|
73
|
+
subprocess.run(["osascript", "-e", script], check=False, capture_output=True)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
# --- commands --------------------------------------------------------------
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def cmd_login(args: argparse.Namespace) -> int:
|
|
80
|
+
email = args.email or input("Amazon email: ").strip()
|
|
81
|
+
password = getpass.getpass("Amazon password: ")
|
|
82
|
+
otp = input("2-Step Verification code from your authenticator app: ").strip()
|
|
83
|
+
try:
|
|
84
|
+
login_data = asyncio.run(amazon.interactive_login(email, password, otp, str(DATA_DIR)))
|
|
85
|
+
except amazon.LoginFailed as exc:
|
|
86
|
+
print(f"Login failed: {exc}", file=sys.stderr)
|
|
87
|
+
return 1
|
|
88
|
+
save_credentials(email, login_data)
|
|
89
|
+
print("Logged in. Device registration saved to Keychain (password and code were not stored).")
|
|
90
|
+
return 0
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class DryRun:
|
|
94
|
+
"""Wraps a side so writes are logged instead of performed."""
|
|
95
|
+
|
|
96
|
+
def __init__(self, side) -> None:
|
|
97
|
+
self.side = side
|
|
98
|
+
self.label = side.label
|
|
99
|
+
self._n = 0
|
|
100
|
+
|
|
101
|
+
def fetch(self):
|
|
102
|
+
return self.side.fetch()
|
|
103
|
+
|
|
104
|
+
def add(self, name, completed):
|
|
105
|
+
print(f"[dry-run] {self.label}: add {name!r}")
|
|
106
|
+
self._n += 1
|
|
107
|
+
return f"dry-{self._n}"
|
|
108
|
+
|
|
109
|
+
def update(self, item_id, *, name, completed):
|
|
110
|
+
print(f"[dry-run] {self.label}: update {item_id} name={name!r} completed={completed!r}")
|
|
111
|
+
|
|
112
|
+
def delete(self, item_id):
|
|
113
|
+
print(f"[dry-run] {self.label}: delete {item_id}")
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def cmd_run(args: argparse.Namespace) -> int:
|
|
117
|
+
with open(LOCK_FILE, "w") as lock:
|
|
118
|
+
try:
|
|
119
|
+
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
120
|
+
except BlockingIOError:
|
|
121
|
+
log.info("another run is in progress; skipping")
|
|
122
|
+
return 0
|
|
123
|
+
state = load_state()
|
|
124
|
+
try:
|
|
125
|
+
return _run(args, state)
|
|
126
|
+
finally:
|
|
127
|
+
if not args.dry_run:
|
|
128
|
+
save_state(state)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _run(args: argparse.Namespace, state: dict) -> int:
|
|
132
|
+
creds = load_credentials()
|
|
133
|
+
if not creds:
|
|
134
|
+
alert(state, "auth", "Not logged in to Amazon. Run: alexa-sync login")
|
|
135
|
+
return 2
|
|
136
|
+
|
|
137
|
+
try:
|
|
138
|
+
check_access()
|
|
139
|
+
reminders = RemindersSide(args.list)
|
|
140
|
+
state["reminders_list_id"] = reminders.resolve(state.get("reminders_list_id"))
|
|
141
|
+
except (RemindersUnavailable, RuntimeError) as exc:
|
|
142
|
+
alert(state, "reminders", str(exc))
|
|
143
|
+
return 2
|
|
144
|
+
|
|
145
|
+
with asyncio.Runner() as runner:
|
|
146
|
+
session = runner.run(_make_session())
|
|
147
|
+
try:
|
|
148
|
+
api = amazon.make_api(session, creds["email"], "", creds["login_data"], str(DATA_DIR))
|
|
149
|
+
client = amazon.AlexaClient(api)
|
|
150
|
+
try:
|
|
151
|
+
runner.run(client.connect())
|
|
152
|
+
list_id = runner.run(client.shopping_list_id())
|
|
153
|
+
alexa = amazon.AlexaSide(client, runner.run, list_id)
|
|
154
|
+
|
|
155
|
+
a_side, r_side = (DryRun(alexa), DryRun(reminders)) if args.dry_run else (alexa, reminders)
|
|
156
|
+
pairs = [Pair.from_dict(p) for p in state.get("pairs", [])]
|
|
157
|
+
pairs = sync(a_side, r_side, pairs, max_deletes=args.max_deletes)
|
|
158
|
+
except amazon.AuthExpired:
|
|
159
|
+
alert(state, "auth", "Amazon login expired. Run: alexa-sync login")
|
|
160
|
+
return 2
|
|
161
|
+
except TooManyDeletes as exc:
|
|
162
|
+
alert(state, "deletes", f"Sync paused: {exc}")
|
|
163
|
+
return 3
|
|
164
|
+
finally:
|
|
165
|
+
if client.login_data_changed and not args.dry_run:
|
|
166
|
+
save_credentials(creds["email"], client.login_data)
|
|
167
|
+
finally:
|
|
168
|
+
runner.run(session.close())
|
|
169
|
+
|
|
170
|
+
if not args.dry_run:
|
|
171
|
+
state["pairs"] = [p.to_dict() for p in pairs]
|
|
172
|
+
state.get("alerts", {}).clear()
|
|
173
|
+
log.info("synced %d paired items", len(pairs))
|
|
174
|
+
return 0
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
async def _make_session():
|
|
178
|
+
from aiohttp import ClientSession
|
|
179
|
+
|
|
180
|
+
return ClientSession()
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _checks(list_name: str) -> list[tuple[str, str | None]]:
|
|
184
|
+
"""(description, problem or None) for everything a background run needs."""
|
|
185
|
+
results: list[tuple[str, str | None]] = []
|
|
186
|
+
try:
|
|
187
|
+
check_access()
|
|
188
|
+
results.append(("remindctl installed with Reminders access", None))
|
|
189
|
+
try:
|
|
190
|
+
RemindersSide(list_name).resolve(load_state().get("reminders_list_id"))
|
|
191
|
+
results.append((f"Reminders list {list_name!r} found", None))
|
|
192
|
+
except RuntimeError as exc:
|
|
193
|
+
results.append((f"Reminders list {list_name!r} found", str(exc)))
|
|
194
|
+
except RemindersUnavailable as exc:
|
|
195
|
+
results.append(("remindctl installed with Reminders access", str(exc)))
|
|
196
|
+
results.append(("logged in to Amazon", None if load_credentials() else "run: alexa-sync login"))
|
|
197
|
+
return results
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def cmd_doctor(args: argparse.Namespace) -> int:
|
|
201
|
+
results = _checks(args.list)
|
|
202
|
+
for what, problem in results:
|
|
203
|
+
print(f"{'✗' if problem else '✓'} {what}" + (f": {problem}" if problem else ""))
|
|
204
|
+
print(f"{'✓' if AGENT_PLIST.exists() else '·'} background job {'installed' if AGENT_PLIST.exists() else 'not installed'}")
|
|
205
|
+
return 1 if any(p for _, p in results) else 0
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def _bootout_all() -> None:
|
|
209
|
+
uid = os.getuid()
|
|
210
|
+
for label in [AGENT_LABEL, *LEGACY_AGENT_LABELS]:
|
|
211
|
+
subprocess.run(["launchctl", "bootout", f"gui/{uid}/{label}"], capture_output=True)
|
|
212
|
+
(LAUNCH_AGENTS / f"{label}.plist").unlink(missing_ok=True)
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def cmd_install(args: argparse.Namespace) -> int:
|
|
216
|
+
problems = [f"{what}: {p}" for what, p in _checks(args.list) if p]
|
|
217
|
+
if problems:
|
|
218
|
+
print("Not installing; fix these first:\n " + "\n ".join(problems), file=sys.stderr)
|
|
219
|
+
return 1
|
|
220
|
+
# Keep the PATH entry (e.g. ~/.local/bin/alexa-sync from `uv tool install`)
|
|
221
|
+
# rather than resolving symlinks, so upgrades don't break the job.
|
|
222
|
+
exe = str(Path(shutil.which("alexa-sync") or sys.argv[0]).absolute())
|
|
223
|
+
AGENT_PLIST.parent.mkdir(parents=True, exist_ok=True)
|
|
224
|
+
LOG_FILE.parent.mkdir(parents=True, exist_ok=True)
|
|
225
|
+
plist = {
|
|
226
|
+
"Label": AGENT_LABEL,
|
|
227
|
+
"ProgramArguments": [exe, "run", "--list", args.list],
|
|
228
|
+
"StartInterval": args.interval,
|
|
229
|
+
"RunAtLoad": True,
|
|
230
|
+
"StandardOutPath": str(LOG_FILE),
|
|
231
|
+
"StandardErrorPath": str(LOG_FILE),
|
|
232
|
+
"EnvironmentVariables": {"PATH": "/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin"},
|
|
233
|
+
}
|
|
234
|
+
_bootout_all()
|
|
235
|
+
AGENT_PLIST.write_bytes(plistlib.dumps(plist))
|
|
236
|
+
subprocess.run(["launchctl", "bootstrap", f"gui/{os.getuid()}", str(AGENT_PLIST)], check=True)
|
|
237
|
+
print(f"Installed {AGENT_PLIST} (every {args.interval}s). Logs: {LOG_FILE}")
|
|
238
|
+
return 0
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def cmd_uninstall(args: argparse.Namespace) -> int:
|
|
242
|
+
_bootout_all()
|
|
243
|
+
print("Uninstalled. Login stays in Keychain and sync state in", DATA_DIR)
|
|
244
|
+
return 0
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def main() -> int:
|
|
248
|
+
parser = argparse.ArgumentParser(prog="alexa-sync", description=__doc__)
|
|
249
|
+
parser.add_argument("-v", "--verbose", action="store_true")
|
|
250
|
+
parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
|
|
251
|
+
sub = parser.add_subparsers(dest="cmd", required=True)
|
|
252
|
+
|
|
253
|
+
p = sub.add_parser("login", help="register this Mac with Amazon (one time)")
|
|
254
|
+
p.add_argument("--email")
|
|
255
|
+
p.set_defaults(fn=cmd_login)
|
|
256
|
+
|
|
257
|
+
p = sub.add_parser("run", help="sync once")
|
|
258
|
+
p.add_argument("--list", default="Groceries", help="Reminders list name (default: Groceries)")
|
|
259
|
+
p.add_argument("--max-deletes", type=int, default=5, help="abort if a pass would delete more than this")
|
|
260
|
+
p.add_argument("--dry-run", action="store_true", help="show what would change without changing anything")
|
|
261
|
+
p.set_defaults(fn=cmd_run)
|
|
262
|
+
|
|
263
|
+
p = sub.add_parser("install", help="run automatically via launchd")
|
|
264
|
+
p.add_argument("--list", default="Groceries")
|
|
265
|
+
p.add_argument("--interval", type=int, default=120, help="seconds between runs (default: 120)")
|
|
266
|
+
p.set_defaults(fn=cmd_install)
|
|
267
|
+
|
|
268
|
+
p = sub.add_parser("uninstall", help="remove the launchd job")
|
|
269
|
+
p.set_defaults(fn=cmd_uninstall)
|
|
270
|
+
|
|
271
|
+
p = sub.add_parser("doctor", help="check that everything needed is set up")
|
|
272
|
+
p.add_argument("--list", default="Groceries")
|
|
273
|
+
p.set_defaults(fn=cmd_doctor)
|
|
274
|
+
|
|
275
|
+
args = parser.parse_args()
|
|
276
|
+
if sys.platform != "darwin":
|
|
277
|
+
parser.exit(1, "alexa-sync only runs on macOS (it needs Apple Reminders).\n")
|
|
278
|
+
logging.basicConfig(
|
|
279
|
+
level=logging.DEBUG if args.verbose else logging.INFO,
|
|
280
|
+
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
|
|
281
|
+
)
|
|
282
|
+
if not args.verbose:
|
|
283
|
+
logging.getLogger("aioamazondevices").setLevel(logging.WARNING)
|
|
284
|
+
DATA_DIR.mkdir(parents=True, exist_ok=True)
|
|
285
|
+
return args.fn(args)
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
if __name__ == "__main__":
|
|
289
|
+
sys.exit(main())
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""Apple Reminders side of the sync, via the remindctl CLI."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import shutil
|
|
7
|
+
import subprocess
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from .sync import Item
|
|
11
|
+
|
|
12
|
+
INSTALL_HINT = "install it with: brew install steipete/tap/remindctl"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class RemindersUnavailable(Exception):
|
|
16
|
+
pass
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def find_remindctl() -> str:
|
|
20
|
+
# launchd jobs get a minimal PATH, so also look in both Homebrew prefixes.
|
|
21
|
+
for candidate in (shutil.which("remindctl"), "/opt/homebrew/bin/remindctl", "/usr/local/bin/remindctl"):
|
|
22
|
+
if candidate and Path(candidate).exists():
|
|
23
|
+
return candidate
|
|
24
|
+
raise RemindersUnavailable(f"remindctl not found; {INSTALL_HINT}")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def check_access() -> None:
|
|
28
|
+
"""Raise RemindersUnavailable with a fix-it message if we can't use Reminders."""
|
|
29
|
+
proc = subprocess.run([find_remindctl(), "status", "--json"], capture_output=True, text=True, timeout=30)
|
|
30
|
+
try:
|
|
31
|
+
authorized = json.loads(proc.stdout).get("authorized")
|
|
32
|
+
except ValueError:
|
|
33
|
+
authorized = False
|
|
34
|
+
if not authorized:
|
|
35
|
+
raise RemindersUnavailable("no access to Reminders; run: remindctl authorize")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class RemindersSide:
|
|
39
|
+
label = "Reminders"
|
|
40
|
+
|
|
41
|
+
def __init__(self, list_name: str) -> None:
|
|
42
|
+
self.list_name = list_name
|
|
43
|
+
self.list_id: str | None = None
|
|
44
|
+
self._bin = find_remindctl()
|
|
45
|
+
|
|
46
|
+
def _run(self, *args: str) -> object:
|
|
47
|
+
proc = subprocess.run(
|
|
48
|
+
[self._bin, *args, "--json", "--no-input", "--no-color"],
|
|
49
|
+
capture_output=True,
|
|
50
|
+
text=True,
|
|
51
|
+
timeout=60,
|
|
52
|
+
)
|
|
53
|
+
if proc.returncode != 0:
|
|
54
|
+
raise RuntimeError(f"remindctl {args[0]} failed: {proc.stderr.strip() or proc.stdout.strip()}")
|
|
55
|
+
return json.loads(proc.stdout) if proc.stdout.strip() else None
|
|
56
|
+
|
|
57
|
+
def resolve(self, pinned_id: str | None) -> str:
|
|
58
|
+
"""Find the target list by ID (preferred) or unique name. Never creates one.
|
|
59
|
+
|
|
60
|
+
Shared iCloud lists can be missing from EventKit for a moment after a
|
|
61
|
+
process starts, so creating a list whenever the name isn't found would
|
|
62
|
+
make duplicates. Pinning the ID also survives two lists sharing a name.
|
|
63
|
+
"""
|
|
64
|
+
lists = self._run("list")
|
|
65
|
+
by_id = {l["id"]: l for l in lists}
|
|
66
|
+
if pinned_id in by_id and by_id[pinned_id]["title"] == self.list_name:
|
|
67
|
+
self.list_id = pinned_id
|
|
68
|
+
return pinned_id
|
|
69
|
+
matches = [l["id"] for l in lists if l["title"] == self.list_name]
|
|
70
|
+
if not matches:
|
|
71
|
+
raise RuntimeError(f"no Reminders list named {self.list_name!r}; create it in Reminders first")
|
|
72
|
+
if len(matches) > 1:
|
|
73
|
+
raise RuntimeError(
|
|
74
|
+
f"{len(matches)} Reminders lists are named {self.list_name!r}; rename or delete the extras"
|
|
75
|
+
)
|
|
76
|
+
self.list_id = matches[0]
|
|
77
|
+
return self.list_id
|
|
78
|
+
|
|
79
|
+
def fetch(self) -> dict[str, Item]:
|
|
80
|
+
rows = self._run("show", "all", "--list", self.list_name)
|
|
81
|
+
return {r["id"]: Item(r["id"], r["title"], r["isCompleted"]) for r in rows if r["listID"] == self.list_id}
|
|
82
|
+
|
|
83
|
+
def add(self, name: str, completed: bool) -> str:
|
|
84
|
+
row = self._run("add", "--title", name, "--list", self.list_name)
|
|
85
|
+
if row["listID"] != self.list_id:
|
|
86
|
+
self._run("delete", row["id"], "--force")
|
|
87
|
+
raise RuntimeError(f"remindctl added {name!r} to list {row['listID']}, not {self.list_id}")
|
|
88
|
+
if completed:
|
|
89
|
+
self._run("edit", row["id"], "--complete")
|
|
90
|
+
return row["id"]
|
|
91
|
+
|
|
92
|
+
def update(self, item_id: str, *, name: str | None, completed: bool | None) -> None:
|
|
93
|
+
args = ["edit", item_id]
|
|
94
|
+
if name is not None:
|
|
95
|
+
args += ["--title", name]
|
|
96
|
+
if completed is not None:
|
|
97
|
+
args.append("--complete" if completed else "--incomplete")
|
|
98
|
+
self._run(*args)
|
|
99
|
+
|
|
100
|
+
def delete(self, item_id: str) -> None:
|
|
101
|
+
self._run("delete", item_id, "--force")
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
"""Two-way reconciliation between the Alexa shopping list and a Reminders list.
|
|
2
|
+
|
|
3
|
+
The sync is state-based: we remember what each paired item looked like after the
|
|
4
|
+
last successful sync, so on the next run we can tell which side changed. Neither
|
|
5
|
+
side exposes reliable modification timestamps through the tools we use, so this
|
|
6
|
+
three-way comparison (Alexa now, Reminders now, last synced) is what lets
|
|
7
|
+
changes flow in both directions.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import logging
|
|
13
|
+
from dataclasses import asdict, dataclass
|
|
14
|
+
from typing import Protocol
|
|
15
|
+
|
|
16
|
+
log = logging.getLogger(__name__)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(frozen=True)
|
|
20
|
+
class Item:
|
|
21
|
+
id: str
|
|
22
|
+
name: str
|
|
23
|
+
completed: bool
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass
|
|
27
|
+
class Pair:
|
|
28
|
+
alexa: str
|
|
29
|
+
reminder: str
|
|
30
|
+
name: str # normalized name at last sync
|
|
31
|
+
completed: bool
|
|
32
|
+
|
|
33
|
+
@classmethod
|
|
34
|
+
def from_dict(cls, d: dict) -> Pair:
|
|
35
|
+
return cls(**d)
|
|
36
|
+
|
|
37
|
+
def to_dict(self) -> dict:
|
|
38
|
+
return asdict(self)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class Side(Protocol):
|
|
42
|
+
"""One end of the sync. Implementations raise on failure."""
|
|
43
|
+
|
|
44
|
+
label: str
|
|
45
|
+
|
|
46
|
+
def fetch(self) -> dict[str, Item]: ...
|
|
47
|
+
def add(self, name: str, completed: bool) -> str: ...
|
|
48
|
+
def update(self, item_id: str, *, name: str | None, completed: bool | None) -> None: ...
|
|
49
|
+
def delete(self, item_id: str) -> None: ...
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class TooManyDeletes(Exception):
|
|
53
|
+
pass
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def normalize(name: str) -> str:
|
|
57
|
+
return " ".join(name.split()).casefold()
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def display(name: str) -> str:
|
|
61
|
+
"""Alexa stores voice-added items lowercase ("milk"); make them look normal."""
|
|
62
|
+
name = " ".join(name.split())
|
|
63
|
+
return name[:1].upper() + name[1:] if name.islower() else name
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@dataclass
|
|
67
|
+
class _Update:
|
|
68
|
+
side: Side
|
|
69
|
+
item_id: str
|
|
70
|
+
name: str | None
|
|
71
|
+
completed: bool | None
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def sync(alexa: Side, reminders: Side, pairs: list[Pair], max_deletes: int = 5) -> list[Pair]:
|
|
75
|
+
"""Run one sync pass and return the new list of pairs to persist."""
|
|
76
|
+
a_items = alexa.fetch()
|
|
77
|
+
r_items = reminders.fetch()
|
|
78
|
+
|
|
79
|
+
deletes: list[tuple[Side, str, Pair]] = []
|
|
80
|
+
updates: list[tuple[_Update, Pair, Pair]] = [] # (update, old pair, new pair)
|
|
81
|
+
kept: list[Pair] = []
|
|
82
|
+
|
|
83
|
+
for p in pairs:
|
|
84
|
+
a = a_items.pop(p.alexa, None)
|
|
85
|
+
r = r_items.pop(p.reminder, None)
|
|
86
|
+
if a is None and r is None:
|
|
87
|
+
continue
|
|
88
|
+
if a is None:
|
|
89
|
+
deletes.append((reminders, p.reminder, p))
|
|
90
|
+
continue
|
|
91
|
+
if r is None:
|
|
92
|
+
deletes.append((alexa, p.alexa, p))
|
|
93
|
+
continue
|
|
94
|
+
|
|
95
|
+
new = Pair(p.alexa, p.reminder, p.name, p.completed)
|
|
96
|
+
a_update = _Update(alexa, a.id, None, None)
|
|
97
|
+
r_update = _Update(reminders, r.id, None, None)
|
|
98
|
+
|
|
99
|
+
# Name: Alexa wins if both sides renamed.
|
|
100
|
+
a_name, r_name = normalize(a.name), normalize(r.name)
|
|
101
|
+
if a_name != p.name:
|
|
102
|
+
new.name = a_name
|
|
103
|
+
if r_name != a_name:
|
|
104
|
+
r_update.name = display(a.name)
|
|
105
|
+
elif r_name != p.name:
|
|
106
|
+
new.name = r_name
|
|
107
|
+
a_update.name = r.name
|
|
108
|
+
|
|
109
|
+
# Completion: Alexa wins if both sides changed.
|
|
110
|
+
if a.completed != p.completed:
|
|
111
|
+
new.completed = a.completed
|
|
112
|
+
if r.completed != a.completed:
|
|
113
|
+
r_update.completed = a.completed
|
|
114
|
+
elif r.completed != p.completed:
|
|
115
|
+
new.completed = r.completed
|
|
116
|
+
a_update.completed = r.completed
|
|
117
|
+
|
|
118
|
+
pending = [u for u in (a_update, r_update) if u.name is not None or u.completed is not None]
|
|
119
|
+
if not pending:
|
|
120
|
+
kept.append(new)
|
|
121
|
+
for i, u in enumerate(pending):
|
|
122
|
+
# Record the new pair only once, against the last update for this item.
|
|
123
|
+
updates.append((u, p, new if i == len(pending) - 1 else None))
|
|
124
|
+
|
|
125
|
+
if len(deletes) > max_deletes:
|
|
126
|
+
raise TooManyDeletes(
|
|
127
|
+
f"refusing to delete {len(deletes)} items in one pass (limit {max_deletes}); "
|
|
128
|
+
"run with --max-deletes to allow it"
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
# Unpaired items. Completed ones are history, not something to copy over.
|
|
132
|
+
a_new = [a for a in a_items.values() if not a.completed]
|
|
133
|
+
r_new = [r for r in r_items.values() if not r.completed]
|
|
134
|
+
|
|
135
|
+
# Pair up identical names first so an initial sync doesn't duplicate items
|
|
136
|
+
# that already exist on both sides.
|
|
137
|
+
r_by_name: dict[str, list[Item]] = {}
|
|
138
|
+
for r in r_new:
|
|
139
|
+
r_by_name.setdefault(normalize(r.name), []).append(r)
|
|
140
|
+
a_unmatched: list[Item] = []
|
|
141
|
+
for a in a_new:
|
|
142
|
+
matches = r_by_name.get(normalize(a.name))
|
|
143
|
+
if matches:
|
|
144
|
+
r = matches.pop(0)
|
|
145
|
+
kept.append(Pair(a.id, r.id, normalize(a.name), False))
|
|
146
|
+
else:
|
|
147
|
+
a_unmatched.append(a)
|
|
148
|
+
r_unmatched = [r for rs in r_by_name.values() for r in rs]
|
|
149
|
+
|
|
150
|
+
result = list(kept)
|
|
151
|
+
|
|
152
|
+
for side, item_id, p in deletes:
|
|
153
|
+
try:
|
|
154
|
+
side.delete(item_id)
|
|
155
|
+
log.info("deleted %r from %s", p.name, side.label)
|
|
156
|
+
except Exception:
|
|
157
|
+
log.exception("failed to delete %r from %s", p.name, side.label)
|
|
158
|
+
result.append(p)
|
|
159
|
+
|
|
160
|
+
failed: set[int] = set()
|
|
161
|
+
for u, old, new in updates:
|
|
162
|
+
try:
|
|
163
|
+
if id(old) not in failed:
|
|
164
|
+
u.side.update(u.item_id, name=u.name, completed=u.completed)
|
|
165
|
+
log.info("updated %r on %s (name=%r, completed=%r)", old.name, u.side.label, u.name, u.completed)
|
|
166
|
+
except Exception:
|
|
167
|
+
log.exception("failed to update %r on %s", old.name, u.side.label)
|
|
168
|
+
failed.add(id(old))
|
|
169
|
+
if new is not None:
|
|
170
|
+
# If any half failed, keep the old snapshot so the change is retried.
|
|
171
|
+
result.append(old if id(old) in failed else new)
|
|
172
|
+
|
|
173
|
+
for a in a_unmatched:
|
|
174
|
+
try:
|
|
175
|
+
rid = reminders.add(display(a.name), False)
|
|
176
|
+
result.append(Pair(a.id, rid, normalize(a.name), False))
|
|
177
|
+
log.info("added %r to %s", a.name, reminders.label)
|
|
178
|
+
except Exception:
|
|
179
|
+
log.exception("failed to add %r to %s", a.name, reminders.label)
|
|
180
|
+
|
|
181
|
+
for r in r_unmatched:
|
|
182
|
+
try:
|
|
183
|
+
aid = alexa.add(r.name, False)
|
|
184
|
+
result.append(Pair(aid, r.id, normalize(r.name), False))
|
|
185
|
+
log.info("added %r to %s", r.name, alexa.label)
|
|
186
|
+
except Exception:
|
|
187
|
+
log.exception("failed to add %r to %s", r.name, alexa.label)
|
|
188
|
+
|
|
189
|
+
return result
|