napper-sync 0.1.0__py3-none-any.whl
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.
- napper_sync/__init__.py +7 -0
- napper_sync/categories.py +314 -0
- napper_sync/cli.py +389 -0
- napper_sync/config.py +140 -0
- napper_sync/famly.py +157 -0
- napper_sync/famly_mapping.py +130 -0
- napper_sync/napper.py +451 -0
- napper_sync/notify.py +94 -0
- napper_sync/sources.py +81 -0
- napper_sync/sync.py +270 -0
- napper_sync-0.1.0.dist-info/METADATA +223 -0
- napper_sync-0.1.0.dist-info/RECORD +16 -0
- napper_sync-0.1.0.dist-info/WHEEL +5 -0
- napper_sync-0.1.0.dist-info/entry_points.txt +2 -0
- napper_sync-0.1.0.dist-info/licenses/LICENSE +21 -0
- napper_sync-0.1.0.dist-info/top_level.txt +1 -0
napper_sync/cli.py
ADDED
|
@@ -0,0 +1,389 @@
|
|
|
1
|
+
"""The ``napper-sync`` command line."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import json
|
|
7
|
+
import logging
|
|
8
|
+
import sys
|
|
9
|
+
from datetime import date, datetime, timedelta
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
from . import __version__, categories, famly_mapping, notify, sources
|
|
13
|
+
from . import config as cfg
|
|
14
|
+
from .famly import FamlyAuthError, FamlyClient, FamlyError, save_token
|
|
15
|
+
from .napper import NapperAuthError, NapperClient, NapperError
|
|
16
|
+
from .sync import SyncResult, sync
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class CliError(Exception):
|
|
20
|
+
"""A user-facing error: printed without a traceback, exit code 1."""
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
# ---------------------------------------------------------------------------
|
|
24
|
+
# helpers
|
|
25
|
+
# ---------------------------------------------------------------------------
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _baby(args, conf: cfg.Config) -> str:
|
|
29
|
+
baby = getattr(args, "baby", None) or conf.baby_id
|
|
30
|
+
if not baby:
|
|
31
|
+
raise CliError(
|
|
32
|
+
"No baby ID. Pass --baby or set [napper] baby_id in "
|
|
33
|
+
f"{cfg.config_path()} (see `napper-sync babies`)."
|
|
34
|
+
)
|
|
35
|
+
return baby
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _child(args, conf: cfg.Config) -> str:
|
|
39
|
+
child = getattr(args, "child", None) or conf.famly_child_id
|
|
40
|
+
if not child:
|
|
41
|
+
raise CliError(
|
|
42
|
+
"No Famly child ID. Pass --child or set [famly] child_id in "
|
|
43
|
+
f"{cfg.config_path()} (see `napper-sync famly children`)."
|
|
44
|
+
)
|
|
45
|
+
return child
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _parse_fields(pairs: list[str]) -> dict:
|
|
49
|
+
fields = {}
|
|
50
|
+
for pair in pairs or []:
|
|
51
|
+
if "=" not in pair:
|
|
52
|
+
raise CliError(f"--set expects FIELD=VALUE, got '{pair}'")
|
|
53
|
+
key, value = pair.split("=", 1)
|
|
54
|
+
fields[key.strip()] = value.strip()
|
|
55
|
+
return fields
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _dump(obj) -> None:
|
|
59
|
+
print(json.dumps(obj, indent=2, ensure_ascii=False))
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _summary_title(result: SyncResult) -> str:
|
|
63
|
+
def entries(n: int) -> str:
|
|
64
|
+
return "entry" if n == 1 else "entries"
|
|
65
|
+
|
|
66
|
+
if result.written and result.updated:
|
|
67
|
+
return (
|
|
68
|
+
f"{result.written + result.updated} nursery entries: "
|
|
69
|
+
f"{result.written} imported, {result.updated} corrected"
|
|
70
|
+
)
|
|
71
|
+
if result.updated:
|
|
72
|
+
return f"{result.updated} nursery {entries(result.updated)} corrected"
|
|
73
|
+
return f"{result.written} nursery {entries(result.written)} imported"
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
# ---------------------------------------------------------------------------
|
|
77
|
+
# commands
|
|
78
|
+
# ---------------------------------------------------------------------------
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def cmd_login(args, conf):
|
|
82
|
+
client = NapperClient()
|
|
83
|
+
code = args.code
|
|
84
|
+
if not code:
|
|
85
|
+
client.send_otp(args.email)
|
|
86
|
+
print(f"A one-time code has been sent to {args.email}.")
|
|
87
|
+
try:
|
|
88
|
+
code = input("Code: ").strip()
|
|
89
|
+
except EOFError as err:
|
|
90
|
+
raise CliError("No code entered. Re-run with --code <CODE>.") from err
|
|
91
|
+
tokens = client.email_login(args.email, code)
|
|
92
|
+
print(f"Logged in. Account {tokens.account_id}; tokens stored in {cfg.napper_tokens_path()}")
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def cmd_babies(args, conf):
|
|
96
|
+
for baby in NapperClient().babies():
|
|
97
|
+
print(f"{baby['id']}\t{baby.get('name')}")
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def cmd_categories(args, conf):
|
|
101
|
+
print(categories.overview())
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def cmd_logs(args, conf):
|
|
105
|
+
_dump(NapperClient().logs_between(_baby(args, conf), args.start, args.end))
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def cmd_today(args, conf):
|
|
109
|
+
_dump(NapperClient().today(_baby(args, conf)))
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def cmd_add(args, conf):
|
|
113
|
+
start = datetime.fromisoformat(args.start).astimezone()
|
|
114
|
+
end = datetime.fromisoformat(args.end).astimezone() if args.end else None
|
|
115
|
+
result = NapperClient().create_log(
|
|
116
|
+
_baby(args, conf),
|
|
117
|
+
args.category,
|
|
118
|
+
start,
|
|
119
|
+
end,
|
|
120
|
+
fields=_parse_fields(args.set),
|
|
121
|
+
comment=args.comment,
|
|
122
|
+
log_id=args.id,
|
|
123
|
+
dry_run=not args.live,
|
|
124
|
+
)
|
|
125
|
+
_dump(result)
|
|
126
|
+
if not args.live:
|
|
127
|
+
print("\nDry run - nothing was written. Pass --live to write.", file=sys.stderr)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def cmd_delete(args, conf):
|
|
131
|
+
_dump(NapperClient().delete_log(_baby(args, conf), args.log_id, dry_run=not args.live))
|
|
132
|
+
if not args.live:
|
|
133
|
+
print("\nDry run - nothing was deleted. Pass --live to delete.", file=sys.stderr)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def cmd_sync_file(args, conf):
|
|
137
|
+
entries = sources.load(Path(args.path), args.format)
|
|
138
|
+
sync(NapperClient(), _baby(args, conf), entries, live=args.live)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def cmd_famly_token(args, conf):
|
|
142
|
+
path = save_token(args.value)
|
|
143
|
+
print(f"Famly token stored in {path}")
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def cmd_famly_children(args, conf):
|
|
147
|
+
for child in FamlyClient(host=conf.famly_host).children():
|
|
148
|
+
print(f"{child['id']}\t{child['name']}")
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def cmd_famly_calendar(args, conf):
|
|
152
|
+
events = FamlyClient(host=conf.famly_host).calendar(_child(args, conf), args.start, args.end)
|
|
153
|
+
if args.json:
|
|
154
|
+
_dump(events)
|
|
155
|
+
return
|
|
156
|
+
print(f"{len(events)} events")
|
|
157
|
+
for ev in events:
|
|
158
|
+
kind = (ev.get("embed") or {}).get("type") or ("SLEEP" if ev.get("to") else "?")
|
|
159
|
+
print(f" {ev.get('day')} {kind:<18} {str(ev.get('title'))[:70]}")
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def cmd_sync_famly(args, conf) -> int:
|
|
163
|
+
baby = _baby(args, conf)
|
|
164
|
+
child = _child(args, conf)
|
|
165
|
+
webhook = None if args.no_notify else conf.discord_webhook
|
|
166
|
+
days = args.days if args.days is not None else conf.sync_days
|
|
167
|
+
end = date.today()
|
|
168
|
+
start = end - timedelta(days=days)
|
|
169
|
+
|
|
170
|
+
try:
|
|
171
|
+
events = FamlyClient(host=conf.famly_host).calendar(
|
|
172
|
+
child, start.isoformat(), end.isoformat()
|
|
173
|
+
)
|
|
174
|
+
except FamlyAuthError as exc:
|
|
175
|
+
print(f"Famly: {exc}", file=sys.stderr)
|
|
176
|
+
if args.live:
|
|
177
|
+
notify.send(
|
|
178
|
+
webhook,
|
|
179
|
+
"Famly token expired",
|
|
180
|
+
"The sync can no longer reach Famly.\n\n"
|
|
181
|
+
"Get a fresh token: log in to Famly in a browser, copy "
|
|
182
|
+
"`localStorage['famly.accessToken']`, then run\n"
|
|
183
|
+
"```\nnapper-sync famly token <VALUE>\n```\n"
|
|
184
|
+
f"Error:\n```\n{str(exc)[:600]}\n```",
|
|
185
|
+
notify.RED,
|
|
186
|
+
)
|
|
187
|
+
return 1
|
|
188
|
+
except FamlyError as exc:
|
|
189
|
+
print(f"Famly: {exc}", file=sys.stderr)
|
|
190
|
+
if args.live:
|
|
191
|
+
notify.send(webhook, "Famly unreachable", f"```\n{str(exc)[:900]}\n```", notify.YELLOW)
|
|
192
|
+
return 1
|
|
193
|
+
|
|
194
|
+
print(f"Famly: {len(events)} events from {start} to {end}")
|
|
195
|
+
entries, notes = famly_mapping.translate(events, marker=conf.marker, child_id=child)
|
|
196
|
+
|
|
197
|
+
counts: dict[str, int] = {}
|
|
198
|
+
for e in entries:
|
|
199
|
+
counts[e.category] = counts.get(e.category, 0) + 1
|
|
200
|
+
print("Translated: " + (", ".join(f"{k} x{v}" for k, v in sorted(counts.items())) or "nothing"))
|
|
201
|
+
if notes:
|
|
202
|
+
print(f"Not imported ({len(notes)}):")
|
|
203
|
+
for note in notes[:10]:
|
|
204
|
+
print(f" {note}")
|
|
205
|
+
if len(notes) > 10:
|
|
206
|
+
print(f" ... and {len(notes) - 10} more")
|
|
207
|
+
print()
|
|
208
|
+
|
|
209
|
+
try:
|
|
210
|
+
result: SyncResult = sync(NapperClient(), baby, entries, live=args.live)
|
|
211
|
+
except (NapperError, categories.CategoryError) as exc:
|
|
212
|
+
print(f"Napper: {exc}", file=sys.stderr)
|
|
213
|
+
if args.live:
|
|
214
|
+
notify.send(webhook, "Napper write failed", f"```\n{str(exc)[:900]}\n```", notify.RED)
|
|
215
|
+
return 1
|
|
216
|
+
|
|
217
|
+
if args.live and (result.written or result.updated):
|
|
218
|
+
lines = [f"· {e.label()}" for e in result.new]
|
|
219
|
+
lines += [f"· corrected: {e.label()} ({'; '.join(diff)})" for e, diff in result.changed]
|
|
220
|
+
notify.send(webhook, _summary_title(result), "\n".join(lines)[:1600], notify.GREEN)
|
|
221
|
+
return 0
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def cmd_notify(args, conf):
|
|
225
|
+
if args.unit:
|
|
226
|
+
ok = notify.unit_failed(conf.discord_webhook, args.unit)
|
|
227
|
+
else:
|
|
228
|
+
ok = notify.send(
|
|
229
|
+
conf.discord_webhook,
|
|
230
|
+
"Test message",
|
|
231
|
+
"If you can read this, notifications work.",
|
|
232
|
+
notify.GREEN,
|
|
233
|
+
)
|
|
234
|
+
print("sent" if ok else "failed")
|
|
235
|
+
return 0 if ok else 1
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def cmd_config(args, conf):
|
|
239
|
+
path = cfg.config_path()
|
|
240
|
+
if args.action == "path":
|
|
241
|
+
print(path)
|
|
242
|
+
return 0
|
|
243
|
+
if args.action == "init":
|
|
244
|
+
if path.exists() and not args.force:
|
|
245
|
+
raise CliError(f"{path} exists. Use --force to overwrite.")
|
|
246
|
+
cfg.ensure_home()
|
|
247
|
+
path.write_text(cfg.EXAMPLE_CONFIG, encoding="utf-8")
|
|
248
|
+
print(f"Wrote {path} - now fill in the IDs.")
|
|
249
|
+
return 0
|
|
250
|
+
if args.action == "show":
|
|
251
|
+
print(f"# {path}" + ("" if path.exists() else " (missing - defaults shown)"))
|
|
252
|
+
print(f"baby_id = {conf.baby_id or ''}")
|
|
253
|
+
print(f"famly.host = {conf.famly_host}")
|
|
254
|
+
print(f"famly.child_id = {conf.famly_child_id or ''}")
|
|
255
|
+
print(f"sync.days = {conf.sync_days}")
|
|
256
|
+
print(f"sync.marker = {conf.marker}")
|
|
257
|
+
print(f"discord_webhook = {'set' if conf.discord_webhook else 'not set'}")
|
|
258
|
+
print(f"napper tokens = {'present' if cfg.napper_tokens_path().exists() else 'missing'}")
|
|
259
|
+
print(f"famly token = {'present' if cfg.famly_token_path().exists() else 'missing'}")
|
|
260
|
+
return 0
|
|
261
|
+
raise CliError(f"Unknown config action {args.action}")
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
# ---------------------------------------------------------------------------
|
|
265
|
+
# parser
|
|
266
|
+
# ---------------------------------------------------------------------------
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
270
|
+
p = argparse.ArgumentParser(
|
|
271
|
+
prog="napper-sync",
|
|
272
|
+
description="Unofficial Napper client and Famly -> Napper sync.",
|
|
273
|
+
epilog="Writes are dry runs unless --live is given.",
|
|
274
|
+
)
|
|
275
|
+
p.add_argument("--version", action="version", version=f"napper-sync {__version__}")
|
|
276
|
+
p.add_argument("-v", "--verbose", action="store_true", help="debug logging")
|
|
277
|
+
sub = p.add_subparsers(dest="cmd", required=True)
|
|
278
|
+
|
|
279
|
+
s = sub.add_parser("login", help="log in to Napper with an e-mailed one-time code")
|
|
280
|
+
s.add_argument("email")
|
|
281
|
+
s.add_argument("--code", help="the code, if you already have it (otherwise prompted)")
|
|
282
|
+
s.set_defaults(fn=cmd_login)
|
|
283
|
+
|
|
284
|
+
sub.add_parser("babies", help="list baby profiles and their IDs").set_defaults(fn=cmd_babies)
|
|
285
|
+
sub.add_parser("categories", help="list categories and their fields").set_defaults(
|
|
286
|
+
fn=cmd_categories
|
|
287
|
+
)
|
|
288
|
+
|
|
289
|
+
s = sub.add_parser("logs", help="print logs between two days as JSON")
|
|
290
|
+
s.add_argument("start", help="YYYY-MM-DD")
|
|
291
|
+
s.add_argument("end", help="YYYY-MM-DD")
|
|
292
|
+
s.add_argument("--baby", help="baby ID (default: config)")
|
|
293
|
+
s.set_defaults(fn=cmd_logs)
|
|
294
|
+
|
|
295
|
+
s = sub.add_parser("today", help="print today's logs as JSON")
|
|
296
|
+
s.add_argument("--baby")
|
|
297
|
+
s.set_defaults(fn=cmd_today)
|
|
298
|
+
|
|
299
|
+
s = sub.add_parser("add", help="add one log entry")
|
|
300
|
+
s.add_argument("category", help="e.g. nap, diaper, bottle - see `categories`")
|
|
301
|
+
s.add_argument("start", help="ISO time, e.g. 2026-09-18T13:05")
|
|
302
|
+
s.add_argument("end", nargs="?", help="ISO time; only for span categories")
|
|
303
|
+
s.add_argument(
|
|
304
|
+
"--set",
|
|
305
|
+
action="append",
|
|
306
|
+
metavar="FIELD=VALUE",
|
|
307
|
+
default=[],
|
|
308
|
+
help="category-specific field, repeatable",
|
|
309
|
+
)
|
|
310
|
+
s.add_argument("--comment")
|
|
311
|
+
s.add_argument("--id", help="overwrite an existing entry instead of creating one")
|
|
312
|
+
s.add_argument("--baby")
|
|
313
|
+
s.add_argument("--live", action="store_true", help="actually write")
|
|
314
|
+
s.set_defaults(fn=cmd_add)
|
|
315
|
+
|
|
316
|
+
s = sub.add_parser("delete", help="delete one log entry")
|
|
317
|
+
s.add_argument("log_id")
|
|
318
|
+
s.add_argument("--baby")
|
|
319
|
+
s.add_argument("--live", action="store_true", help="actually delete")
|
|
320
|
+
s.set_defaults(fn=cmd_delete)
|
|
321
|
+
|
|
322
|
+
s = sub.add_parser("sync-file", help="import entries from a CSV or JSON file")
|
|
323
|
+
s.add_argument("path")
|
|
324
|
+
s.add_argument("--format", choices=sorted(sources.SOURCES), help="default: by extension")
|
|
325
|
+
s.add_argument("--baby")
|
|
326
|
+
s.add_argument("--live", action="store_true", help="actually write")
|
|
327
|
+
s.set_defaults(fn=cmd_sync_file)
|
|
328
|
+
|
|
329
|
+
f = sub.add_parser("famly", help="Famly side: token, children, calendar")
|
|
330
|
+
fs = f.add_subparsers(dest="famly_cmd", required=True)
|
|
331
|
+
s = fs.add_parser("token", help="store the Famly access token")
|
|
332
|
+
s.add_argument("value")
|
|
333
|
+
s.set_defaults(fn=cmd_famly_token)
|
|
334
|
+
fs.add_parser("children", help="list children and their IDs").set_defaults(
|
|
335
|
+
fn=cmd_famly_children
|
|
336
|
+
)
|
|
337
|
+
s = fs.add_parser("calendar", help="show what the nursery logged")
|
|
338
|
+
s.add_argument("start", help="YYYY-MM-DD")
|
|
339
|
+
s.add_argument("end", help="YYYY-MM-DD")
|
|
340
|
+
s.add_argument("--child")
|
|
341
|
+
s.add_argument("--json", action="store_true", help="raw events as JSON")
|
|
342
|
+
s.set_defaults(fn=cmd_famly_calendar)
|
|
343
|
+
|
|
344
|
+
s = sub.add_parser("sync-famly", help="import the nursery's Famly entries into Napper")
|
|
345
|
+
s.add_argument("--baby")
|
|
346
|
+
s.add_argument("--child")
|
|
347
|
+
s.add_argument("--days", type=int, help="look-back window (default: config, 4)")
|
|
348
|
+
s.add_argument("--live", action="store_true", help="actually write")
|
|
349
|
+
s.add_argument("--no-notify", action="store_true", help="skip Discord messages")
|
|
350
|
+
s.set_defaults(fn=cmd_sync_famly)
|
|
351
|
+
|
|
352
|
+
s = sub.add_parser("notify", help="send a Discord test message, or a unit-failure report")
|
|
353
|
+
s.add_argument("--unit", help="systemd unit whose journal to attach (used by OnFailure=)")
|
|
354
|
+
s.set_defaults(fn=cmd_notify)
|
|
355
|
+
|
|
356
|
+
s = sub.add_parser("config", help="manage the configuration file")
|
|
357
|
+
s.add_argument("action", choices=["init", "show", "path"])
|
|
358
|
+
s.add_argument("--force", action="store_true", help="overwrite an existing config on init")
|
|
359
|
+
s.set_defaults(fn=cmd_config)
|
|
360
|
+
|
|
361
|
+
return p
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def main(argv: list[str] | None = None) -> int:
|
|
365
|
+
args = build_parser().parse_args(argv)
|
|
366
|
+
logging.basicConfig(
|
|
367
|
+
level=logging.DEBUG if args.verbose else logging.INFO,
|
|
368
|
+
format="%(levelname)s %(name)s: %(message)s" if args.verbose else "%(message)s",
|
|
369
|
+
stream=sys.stderr,
|
|
370
|
+
)
|
|
371
|
+
conf = cfg.Config.load()
|
|
372
|
+
try:
|
|
373
|
+
code = args.fn(args, conf)
|
|
374
|
+
return int(code or 0)
|
|
375
|
+
except (
|
|
376
|
+
CliError,
|
|
377
|
+
NapperAuthError,
|
|
378
|
+
NapperError,
|
|
379
|
+
FamlyError,
|
|
380
|
+
categories.CategoryError,
|
|
381
|
+
ValueError,
|
|
382
|
+
OSError,
|
|
383
|
+
) as exc:
|
|
384
|
+
print(f"Error: {exc}", file=sys.stderr)
|
|
385
|
+
return 1
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
if __name__ == "__main__":
|
|
389
|
+
raise SystemExit(main())
|
napper_sync/config.py
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
"""Configuration and on-disk state for napper-sync.
|
|
2
|
+
|
|
3
|
+
Everything lives in one directory so that it is easy to back up, move to a
|
|
4
|
+
server, or wipe:
|
|
5
|
+
|
|
6
|
+
~/.config/napper-sync/
|
|
7
|
+
├── config.toml IDs, host, sync window, Discord webhook
|
|
8
|
+
├── napper-tokens.json written by `napper-sync login`, refreshed automatically
|
|
9
|
+
└── famly-token written by `napper-sync famly token <value>`
|
|
10
|
+
|
|
11
|
+
The directory can be relocated with the ``NAPPER_SYNC_HOME`` environment
|
|
12
|
+
variable (handy for tests and for running several children side by side).
|
|
13
|
+
|
|
14
|
+
Secrets are stored as separate files rather than inside ``config.toml`` so
|
|
15
|
+
that the config can be shared or committed to a private dotfiles repo
|
|
16
|
+
without dragging credentials along.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import contextlib
|
|
22
|
+
import os
|
|
23
|
+
import tomllib
|
|
24
|
+
from dataclasses import dataclass, field
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
|
|
27
|
+
DEFAULT_FAMLY_HOST = "app.famly.de"
|
|
28
|
+
DEFAULT_SYNC_DAYS = 4
|
|
29
|
+
DEFAULT_MARKER = "(Famly)"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def home() -> Path:
|
|
33
|
+
"""The state directory. Created lazily by whoever writes into it."""
|
|
34
|
+
override = os.environ.get("NAPPER_SYNC_HOME", "").strip()
|
|
35
|
+
if override:
|
|
36
|
+
return Path(override).expanduser()
|
|
37
|
+
xdg = os.environ.get("XDG_CONFIG_HOME", "").strip()
|
|
38
|
+
base = Path(xdg).expanduser() if xdg else Path.home() / ".config"
|
|
39
|
+
return base / "napper-sync"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def config_path() -> Path:
|
|
43
|
+
return home() / "config.toml"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def napper_tokens_path() -> Path:
|
|
47
|
+
return home() / "napper-tokens.json"
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def famly_token_path() -> Path:
|
|
51
|
+
return home() / "famly-token"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def ensure_home() -> Path:
|
|
55
|
+
path = home()
|
|
56
|
+
path.mkdir(parents=True, exist_ok=True)
|
|
57
|
+
with contextlib.suppress(OSError): # Windows has no POSIX permission bits
|
|
58
|
+
path.chmod(0o700)
|
|
59
|
+
return path
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def write_secret(path: Path, value: str) -> Path:
|
|
63
|
+
"""Write a credential file with owner-only permissions."""
|
|
64
|
+
ensure_home()
|
|
65
|
+
path.write_text(value.strip() + "\n", encoding="utf-8")
|
|
66
|
+
with contextlib.suppress(OSError):
|
|
67
|
+
path.chmod(0o600)
|
|
68
|
+
return path
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@dataclass
|
|
72
|
+
class Config:
|
|
73
|
+
"""Parsed ``config.toml``. Every field has a default, so a missing file
|
|
74
|
+
is not an error - only the commands that need an ID will complain."""
|
|
75
|
+
|
|
76
|
+
baby_id: str | None = None
|
|
77
|
+
famly_host: str = DEFAULT_FAMLY_HOST
|
|
78
|
+
famly_child_id: str | None = None
|
|
79
|
+
sync_days: int = DEFAULT_SYNC_DAYS
|
|
80
|
+
marker: str = DEFAULT_MARKER
|
|
81
|
+
discord_webhook: str | None = None
|
|
82
|
+
raw: dict = field(default_factory=dict, repr=False)
|
|
83
|
+
|
|
84
|
+
@classmethod
|
|
85
|
+
def load(cls, path: Path | None = None) -> Config:
|
|
86
|
+
path = path or config_path()
|
|
87
|
+
if not path.exists():
|
|
88
|
+
return cls()
|
|
89
|
+
with path.open("rb") as fh:
|
|
90
|
+
data = tomllib.load(fh)
|
|
91
|
+
napper = data.get("napper", {}) or {}
|
|
92
|
+
famly = data.get("famly", {}) or {}
|
|
93
|
+
sync = data.get("sync", {}) or {}
|
|
94
|
+
notify = data.get("notify", {}) or {}
|
|
95
|
+
return cls(
|
|
96
|
+
baby_id=_opt_str(napper.get("baby_id")),
|
|
97
|
+
famly_host=_opt_str(famly.get("host")) or DEFAULT_FAMLY_HOST,
|
|
98
|
+
famly_child_id=_opt_str(famly.get("child_id")),
|
|
99
|
+
sync_days=int(sync.get("days", DEFAULT_SYNC_DAYS)),
|
|
100
|
+
marker=str(sync.get("marker", DEFAULT_MARKER)),
|
|
101
|
+
discord_webhook=_opt_str(notify.get("discord_webhook"))
|
|
102
|
+
or _opt_str(os.environ.get("NAPPER_SYNC_DISCORD_WEBHOOK")),
|
|
103
|
+
raw=data,
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _opt_str(value) -> str | None:
|
|
108
|
+
if value is None:
|
|
109
|
+
return None
|
|
110
|
+
text = str(value).strip()
|
|
111
|
+
return text or None
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
EXAMPLE_CONFIG = """\
|
|
115
|
+
# napper-sync configuration
|
|
116
|
+
# Location: ~/.config/napper-sync/config.toml (or $NAPPER_SYNC_HOME/config.toml)
|
|
117
|
+
|
|
118
|
+
[napper]
|
|
119
|
+
# From `napper-sync babies`
|
|
120
|
+
baby_id = ""
|
|
121
|
+
|
|
122
|
+
[famly]
|
|
123
|
+
# Famly runs one instance per country. Yours is shown in the browser under
|
|
124
|
+
# localStorage["famly.existingLogins"] -> baseUrl. Common: app.famly.de, app.famly.co
|
|
125
|
+
host = "app.famly.de"
|
|
126
|
+
# From `napper-sync famly children`
|
|
127
|
+
child_id = ""
|
|
128
|
+
|
|
129
|
+
[sync]
|
|
130
|
+
# How many days back each run looks. Four covers a weekend from the Monday run.
|
|
131
|
+
days = 4
|
|
132
|
+
# Appended to the comment of every entry imported from Famly, so you can tell
|
|
133
|
+
# them apart in the Napper app. Set to "" to disable.
|
|
134
|
+
marker = "(Famly)"
|
|
135
|
+
|
|
136
|
+
[notify]
|
|
137
|
+
# Discord webhook for failures and successful imports. Leave empty to disable.
|
|
138
|
+
# Can also be given via the NAPPER_SYNC_DISCORD_WEBHOOK environment variable.
|
|
139
|
+
discord_webhook = ""
|
|
140
|
+
"""
|
napper_sync/famly.py
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
"""Client for the internal Famly API (the one the web app uses).
|
|
2
|
+
|
|
3
|
+
Famly's official API is a paid add-on for the nursery, not something a
|
|
4
|
+
parent can switch on. The web app, however, talks to a plain REST + GraphQL
|
|
5
|
+
backend with a single auth header::
|
|
6
|
+
|
|
7
|
+
x-famly-accesstoken: <token>
|
|
8
|
+
|
|
9
|
+
The token lives in the logged-in browser under
|
|
10
|
+
``localStorage["famly.accessToken"]`` and is stored here in
|
|
11
|
+
``~/.config/napper-sync/famly-token``.
|
|
12
|
+
|
|
13
|
+
Famly runs one instance per country (``app.famly.de``, ``app.famly.co``, ...).
|
|
14
|
+
Which one applies to you is shown in the browser under
|
|
15
|
+
``localStorage["famly.existingLogins"]`` -> ``baseUrl``.
|
|
16
|
+
|
|
17
|
+
The one endpoint that matters for syncing::
|
|
18
|
+
|
|
19
|
+
GET /api/v2/calendar?type=RANGE&day=<from>&to=<to>&childId=<id>
|
|
20
|
+
|
|
21
|
+
It returns everything the nursery logs for the child: sleep, meals, diaper
|
|
22
|
+
changes, check-in and check-out. See ``docs/famly-api.md``.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import json
|
|
28
|
+
from pathlib import Path
|
|
29
|
+
from typing import Any
|
|
30
|
+
from urllib.error import HTTPError, URLError
|
|
31
|
+
from urllib.parse import urlencode
|
|
32
|
+
from urllib.request import Request, urlopen
|
|
33
|
+
|
|
34
|
+
from . import config as cfg
|
|
35
|
+
|
|
36
|
+
TIMEOUT = 25
|
|
37
|
+
|
|
38
|
+
# Famly answers 403 to requests without a browser-style User-Agent, so the
|
|
39
|
+
# client sends the same header the web app sends.
|
|
40
|
+
USER_AGENT = (
|
|
41
|
+
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
|
|
42
|
+
"(KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36"
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class FamlyError(Exception):
|
|
47
|
+
"""Request failed."""
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class FamlyAuthError(FamlyError):
|
|
51
|
+
"""Token missing, expired or rejected."""
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def save_token(token: str, path: Path | None = None) -> Path:
|
|
55
|
+
return cfg.write_secret(path or cfg.famly_token_path(), token)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def load_token(path: Path | None = None) -> str:
|
|
59
|
+
path = path or cfg.famly_token_path()
|
|
60
|
+
if not path.exists():
|
|
61
|
+
raise FamlyAuthError(
|
|
62
|
+
f"No Famly token in {path}. Copy localStorage['famly.accessToken'] from a "
|
|
63
|
+
"logged-in browser and run: napper-sync famly token <VALUE>"
|
|
64
|
+
)
|
|
65
|
+
return path.read_text(encoding="utf-8").strip()
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class FamlyClient:
|
|
69
|
+
def __init__(
|
|
70
|
+
self, token: str | None = None, host: str | None = None, token_path: Path | None = None
|
|
71
|
+
):
|
|
72
|
+
self.token = token or load_token(token_path)
|
|
73
|
+
self.host = host or cfg.DEFAULT_FAMLY_HOST
|
|
74
|
+
|
|
75
|
+
@property
|
|
76
|
+
def rest_base(self) -> str:
|
|
77
|
+
return f"https://{self.host}/api"
|
|
78
|
+
|
|
79
|
+
@property
|
|
80
|
+
def graphql_url(self) -> str:
|
|
81
|
+
return f"https://{self.host}/graphql"
|
|
82
|
+
|
|
83
|
+
def _headers(self) -> dict[str, str]:
|
|
84
|
+
return {
|
|
85
|
+
"x-famly-accesstoken": self.token,
|
|
86
|
+
"Accept": "application/json",
|
|
87
|
+
"Content-Type": "application/json",
|
|
88
|
+
"User-Agent": USER_AGENT,
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
# ---- REST -------------------------------------------------------------
|
|
92
|
+
|
|
93
|
+
def get(self, path: str, **params) -> Any:
|
|
94
|
+
url = f"{self.rest_base}/{path.lstrip('/')}"
|
|
95
|
+
if params:
|
|
96
|
+
url += "?" + urlencode({k: v for k, v in params.items() if v is not None})
|
|
97
|
+
return self._raw("GET", url)
|
|
98
|
+
|
|
99
|
+
# ---- GraphQL ----------------------------------------------------------
|
|
100
|
+
|
|
101
|
+
def graphql(self, query: str, variables: dict | None = None) -> Any:
|
|
102
|
+
body = {"query": query, "variables": variables or {}}
|
|
103
|
+
answer = self._raw("POST", self.graphql_url, body)
|
|
104
|
+
if isinstance(answer, dict) and answer.get("errors"):
|
|
105
|
+
raise FamlyError(json.dumps(answer["errors"], ensure_ascii=False)[:500])
|
|
106
|
+
return answer.get("data") if isinstance(answer, dict) else answer
|
|
107
|
+
|
|
108
|
+
# ---- shared -----------------------------------------------------------
|
|
109
|
+
|
|
110
|
+
def _raw(self, method: str, url: str, body: dict | None = None) -> Any:
|
|
111
|
+
data = json.dumps(body).encode() if body is not None else None
|
|
112
|
+
req = Request(url, data=data, headers=self._headers(), method=method)
|
|
113
|
+
try:
|
|
114
|
+
with urlopen(req, timeout=TIMEOUT) as resp:
|
|
115
|
+
raw = resp.read()
|
|
116
|
+
except HTTPError as err:
|
|
117
|
+
text = err.read().decode(errors="replace")[:500]
|
|
118
|
+
if err.code in (401, 403):
|
|
119
|
+
raise FamlyAuthError(
|
|
120
|
+
f"Famly at {self.host} rejected the request ({err.code}). Either the "
|
|
121
|
+
"token has expired, or [famly] host is not the instance your account "
|
|
122
|
+
"lives on - check localStorage['famly.existingLogins'] in a logged-in "
|
|
123
|
+
f"browser. Server said: {text}"
|
|
124
|
+
) from err
|
|
125
|
+
raise FamlyError(f"HTTP {err.code} on {method} {url}: {text}") from err
|
|
126
|
+
except URLError as err:
|
|
127
|
+
raise FamlyError(f"Connection failed: {err}") from err
|
|
128
|
+
if not raw:
|
|
129
|
+
return {}
|
|
130
|
+
try:
|
|
131
|
+
return json.loads(raw)
|
|
132
|
+
except json.JSONDecodeError:
|
|
133
|
+
return {"_raw": raw.decode(errors="replace")[:1000]}
|
|
134
|
+
|
|
135
|
+
# ---- convenience ------------------------------------------------------
|
|
136
|
+
|
|
137
|
+
def calendar(self, child_id: str, start: str, end: str) -> list[dict]:
|
|
138
|
+
"""The child's daily events - what the nursery logs.
|
|
139
|
+
|
|
140
|
+
``start``/``end`` as ``YYYY-MM-DD``. Returns a flat list of events,
|
|
141
|
+
each enriched with a ``day`` key.
|
|
142
|
+
"""
|
|
143
|
+
answer = self.get("/v2/calendar", type="RANGE", day=start, to=end, childId=child_id)
|
|
144
|
+
out: list[dict] = []
|
|
145
|
+
for block in answer if isinstance(answer, list) else []:
|
|
146
|
+
for day in block.get("days", []) or []:
|
|
147
|
+
for event in day.get("events", []) or []:
|
|
148
|
+
event = dict(event)
|
|
149
|
+
event["day"] = day.get("day_localdate") or day.get("day")
|
|
150
|
+
out.append(event)
|
|
151
|
+
return out
|
|
152
|
+
|
|
153
|
+
def children(self) -> list[dict]:
|
|
154
|
+
"""Children attached to the account (via GraphQL)."""
|
|
155
|
+
data = self.graphql("{ children { results { id name { fullName } } } }")
|
|
156
|
+
results = ((data or {}).get("children") or {}).get("results") or []
|
|
157
|
+
return [{"id": c.get("id"), "name": (c.get("name") or {}).get("fullName")} for c in results]
|