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/sources.py ADDED
@@ -0,0 +1,81 @@
1
+ """File-based sources: CSV and JSON.
2
+
3
+ Both share one row format - the fixed columns ``start``, ``end``,
4
+ ``category``, ``comment``, and any further column is taken as a
5
+ category-specific field::
6
+
7
+ start,end,category,comment,feedingAmount,bottleFeedingType,diaperContent
8
+ 2026-09-17T13:10:00,2026-09-17T14:35:00,nap,,,,
9
+ 2026-09-17T15:00:00,,bottle,,120,FORMULA,
10
+ 2026-09-17T15:30:00,,diaper,,,,WET
11
+
12
+ Empty cells are ignored. Categories may be given by API name or alias. An
13
+ optional ``id`` column (``LOG_<uuid>_<CATEGORY>``) pins the Napper ID, which
14
+ makes a repeated import of the same file update instead of duplicate.
15
+
16
+ A JSON file holds a list of objects with the same keys; extra fields may sit
17
+ next to the fixed ones or be grouped under ``"fields"``. That is the hook
18
+ for anything else - another tracker, a Home Assistant export, a sensor
19
+ script: whatever can write such a list can feed Napper.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import csv
25
+ import json
26
+ from collections.abc import Callable
27
+ from pathlib import Path
28
+
29
+ from .sync import Entry, parse_time
30
+
31
+ FIXED_COLUMNS = {"id", "start", "end", "category", "comment"}
32
+
33
+
34
+ def from_csv(path: Path) -> list[Entry]:
35
+ out: list[Entry] = []
36
+ with Path(path).open(newline="", encoding="utf-8-sig") as fh:
37
+ for row in csv.DictReader(fh):
38
+ if not (row.get("start") or "").strip():
39
+ continue
40
+ out.append(_build(row))
41
+ return out
42
+
43
+
44
+ def from_json(path: Path) -> list[Entry]:
45
+ data = json.loads(Path(path).read_text(encoding="utf-8"))
46
+ if not isinstance(data, list):
47
+ raise ValueError("JSON source must be a list of entries")
48
+ return [_build(d) for d in data]
49
+
50
+
51
+ def _build(raw: dict) -> Entry:
52
+ fields = dict(raw.get("fields") or {})
53
+ for key, value in raw.items():
54
+ if key in FIXED_COLUMNS or key == "fields":
55
+ continue
56
+ if value is None or (isinstance(value, str) and not value.strip()):
57
+ continue
58
+ fields[key] = value
59
+ return Entry(
60
+ start=parse_time(raw["start"]),
61
+ end=parse_time(raw["end"]) if str(raw.get("end") or "").strip() else None,
62
+ category=str(raw.get("category") or "NAP"),
63
+ comment=str(raw.get("comment") or "").strip(),
64
+ fields=fields,
65
+ log_id=str(raw.get("id") or "").strip() or None,
66
+ )
67
+
68
+
69
+ SOURCES: dict[str, Callable[[Path], list[Entry]]] = {
70
+ "csv": from_csv,
71
+ "json": from_json,
72
+ }
73
+
74
+
75
+ def load(path: Path, fmt: str | None = None) -> list[Entry]:
76
+ """Load a file, picking the parser from ``fmt`` or the file extension."""
77
+ path = Path(path)
78
+ fmt = (fmt or path.suffix.lstrip(".")).lower()
79
+ if fmt not in SOURCES:
80
+ raise ValueError(f"Unknown source format '{fmt}'. Known: {', '.join(SOURCES)}")
81
+ return SOURCES[fmt](path)
napper_sync/sync.py ADDED
@@ -0,0 +1,270 @@
1
+ """The sync core: entries, duplicate detection, and the write loop.
2
+
3
+ A sync run is deliberately boring:
4
+
5
+ 1. a *source* yields :class:`Entry` objects (CSV, JSON, Famly, ...)
6
+ 2. the entries already in Napper for that period are fetched
7
+ 3. anything that is already there is skipped
8
+ 4. the rest is written
9
+
10
+ Because of step 3 a repeated run is harmless, which is what makes an hourly
11
+ timer safe. Writing is opt-in (``live=True``); the default is a dry run.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import logging
17
+ from collections.abc import Iterable
18
+ from dataclasses import dataclass, field
19
+ from datetime import datetime, timedelta
20
+
21
+ from . import categories
22
+ from .napper import NapperClient, decode_comment
23
+
24
+ log = logging.getLogger(__name__)
25
+
26
+ # Two entries of the same category count as the same if their start times are
27
+ # closer than this - or if their spans overlap.
28
+ FUZZ = timedelta(minutes=10)
29
+
30
+
31
+ @dataclass
32
+ class Entry:
33
+ start: datetime
34
+ end: datetime | None = None
35
+ category: str = "NAP"
36
+ comment: str = ""
37
+ fields: dict = field(default_factory=dict)
38
+ log_id: str | None = None # a stable ID makes a repeated write an update
39
+ raw: dict | None = field(default=None, repr=False, compare=False) # as read from Napper
40
+
41
+ def __post_init__(self) -> None:
42
+ cat = categories.get(self.category)
43
+ self.category = cat.name
44
+ self.fields = categories.validate(cat.name, self.fields)
45
+ if not cat.span:
46
+ self.end = self.start
47
+ elif self.end and self.end <= self.start:
48
+ raise ValueError(
49
+ f"{self.category}: end is not after start ({self.start} -> {self.end})"
50
+ )
51
+
52
+ @property
53
+ def cat(self) -> categories.Category:
54
+ return categories.CATEGORIES[self.category]
55
+
56
+ def label(self) -> str:
57
+ text = f"{self.category} {self.start:%Y-%m-%d %H:%M}"
58
+ if self.cat.span:
59
+ text += f" -> {self.end:%H:%M}" if self.end else " (open)"
60
+ if self.fields:
61
+ text += " " + " ".join(f"{k}={v}" for k, v in self.fields.items())
62
+ if self.comment:
63
+ text += f' "{self.comment}"'
64
+ return text
65
+
66
+ @classmethod
67
+ def from_napper(cls, item: dict) -> Entry:
68
+ """An entry as it exists in Napper.
69
+
70
+ Fields are taken as they are, not validated: a value this version
71
+ does not know must not make the entry invisible to duplicate
72
+ detection.
73
+ """
74
+ cat = categories.CATEGORIES[str(item.get("category") or "").upper()]
75
+ entry = cls(
76
+ start=parse_time(item["start"]),
77
+ end=parse_time(item["end"]) if item.get("end") else None,
78
+ category=cat.name,
79
+ comment=decode_comment(item.get("comment")),
80
+ log_id=str(item.get("id") or "") or None,
81
+ raw=item,
82
+ )
83
+ entry.fields = {f.name: item[f.name] for f in cat.fields if f.name in item}
84
+ return entry
85
+
86
+
87
+ @dataclass
88
+ class SyncResult:
89
+ written: int
90
+ skipped: int
91
+ new: list[Entry]
92
+ live: bool
93
+ updated: int = 0
94
+ changed: list[tuple[Entry, list[str]]] = field(default_factory=list)
95
+
96
+ @property
97
+ def planned(self) -> int:
98
+ """Entries a live run would write: new ones plus corrections."""
99
+ return len(self.new) + len(self.changed)
100
+
101
+
102
+ def parse_time(value) -> datetime:
103
+ """ISO string -> timezone-aware datetime.
104
+
105
+ A value without a zone is taken as local time - which is what you want
106
+ for hand-written timestamps in a CSV.
107
+ """
108
+ if isinstance(value, datetime):
109
+ return value.astimezone()
110
+ return datetime.fromisoformat(str(value).strip().replace("Z", "+00:00")).astimezone()
111
+
112
+
113
+ def existing_entries(
114
+ client: NapperClient, baby_id: str, first: datetime, last: datetime
115
+ ) -> list[Entry]:
116
+ """Fetch what is already logged in Napper for the period, with a day of
117
+ slack on either side for timezone edges."""
118
+ raw = client.logs_between(
119
+ baby_id,
120
+ (first.date() - timedelta(days=1)).isoformat(),
121
+ (last.date() + timedelta(days=1)).isoformat(),
122
+ )
123
+ out: list[Entry] = []
124
+ for item in raw:
125
+ cat = (item.get("category") or "").upper()
126
+ if cat not in categories.CATEGORIES or not item.get("start"):
127
+ continue
128
+ try:
129
+ out.append(Entry.from_napper(item))
130
+ except (ValueError, KeyError):
131
+ continue # tolerate malformed historical data
132
+ return out
133
+
134
+
135
+ def is_duplicate(candidate: Entry, existing: Iterable[Entry]) -> bool:
136
+ for other in existing:
137
+ if other.category != candidate.category:
138
+ continue
139
+ # Day markers: one per day, so the same day already counts.
140
+ if candidate.cat.id_scheme == "date":
141
+ if other.start.date() == candidate.start.date():
142
+ return True
143
+ continue
144
+ if abs(other.start - candidate.start) <= FUZZ:
145
+ return True
146
+ if (
147
+ candidate.cat.span
148
+ and candidate.end
149
+ and other.end
150
+ and candidate.start < other.end
151
+ and other.start < candidate.end
152
+ ):
153
+ return True
154
+ return False
155
+
156
+
157
+ def changes(candidate: Entry, other: Entry) -> list[str]:
158
+ """What writing ``candidate`` would change on ``other`` (same ID).
159
+
160
+ Only what the source owns is compared: start, end, comment and the
161
+ fields the source sets. Anything else on the Napper entry is left alone.
162
+ An empty list means there is nothing to do.
163
+ """
164
+ out: list[str] = []
165
+ if _ms(candidate.start) != _ms(other.start):
166
+ out.append(f"start {other.start:%H:%M} -> {candidate.start:%H:%M}")
167
+ if candidate.cat.span and _ms(candidate.end) != _ms(other.end):
168
+ out.append(f"end {_hm(other.end)} -> {_hm(candidate.end)}")
169
+ if candidate.comment != other.comment:
170
+ out.append(f"comment {other.comment!r} -> {candidate.comment!r}")
171
+ for name, value in candidate.fields.items():
172
+ if other.fields.get(name) != value:
173
+ out.append(f"{name} {other.fields.get(name)} -> {value}")
174
+ return out
175
+
176
+
177
+ def _ms(dt: datetime | None) -> datetime | None:
178
+ """Napper stores milliseconds; compare at that precision."""
179
+ return dt if dt is None else dt.replace(microsecond=dt.microsecond // 1000 * 1000)
180
+
181
+
182
+ def _hm(dt: datetime | None) -> str:
183
+ return f"{dt:%H:%M}" if dt else "open"
184
+
185
+
186
+ def sync(
187
+ client: NapperClient, baby_id: str, entries: list[Entry], *, live: bool = False, report=print
188
+ ) -> SyncResult:
189
+ """Write the entries that are not in Napper yet.
190
+
191
+ ``report`` receives human-readable progress lines; pass a no-op to keep
192
+ the run quiet.
193
+ """
194
+ if not entries:
195
+ report("Source returned nothing.")
196
+ return SyncResult(0, 0, [], live)
197
+
198
+ first = min(e.start for e in entries)
199
+ last = max((e.end or e.start) for e in entries)
200
+ report(f"Source: {len(entries)} entries from {first:%Y-%m-%d} to {last:%Y-%m-%d}")
201
+
202
+ existing = existing_entries(client, baby_id, first, last)
203
+ by_id = {e.log_id: e for e in existing if e.log_id}
204
+ report(f"Already in Napper for that period: {len(existing)} logs")
205
+
206
+ # Three outcomes per source entry:
207
+ # same ID already in Napper -> correction if anything the source owns differs
208
+ # same category close by -> duplicate, left alone (older imports, manual entries)
209
+ # otherwise -> new
210
+ # Decided incrementally so that two near-identical entries in the *source*
211
+ # also collapse into one - not only duplicates of what Napper already has.
212
+ new: list[Entry] = []
213
+ corrections: list[tuple[Entry, Entry, list[str]]] = []
214
+ for entry in sorted(entries, key=lambda e: e.start):
215
+ other = by_id.get(entry.log_id) if entry.log_id else None
216
+ if other is not None:
217
+ diff = changes(entry, other) if other.raw is not None else []
218
+ if diff:
219
+ corrections.append((entry, other, diff))
220
+ continue
221
+ if is_duplicate(entry, existing):
222
+ continue
223
+ new.append(entry)
224
+ existing.append(entry)
225
+ if entry.log_id:
226
+ by_id[entry.log_id] = entry
227
+ skipped = len(entries) - len(new) - len(corrections)
228
+ report(f"To write: {len(new)} new, {len(corrections)} to correct (unchanged: {skipped})")
229
+
230
+ written = updated = 0
231
+ for entry in new:
232
+ result = client.create_log(
233
+ baby_id,
234
+ entry.category,
235
+ entry.start,
236
+ entry.end,
237
+ fields=entry.fields,
238
+ comment=entry.comment,
239
+ log_id=entry.log_id,
240
+ dry_run=not live,
241
+ )
242
+ if live:
243
+ report(f" written: {entry.label()} id={result['log_id']}")
244
+ written += 1
245
+ else:
246
+ report(f" [dry-run] {entry.label()}")
247
+ for entry, other, diff in corrections:
248
+ result = client.update_log(
249
+ baby_id,
250
+ other.raw or {},
251
+ start=entry.start,
252
+ end=entry.end,
253
+ fields=entry.fields,
254
+ comment=entry.comment,
255
+ dry_run=not live,
256
+ )
257
+ what = "; ".join(diff)
258
+ if live:
259
+ report(f" corrected: {entry.label()} id={result['log_id']} ({what})")
260
+ updated += 1
261
+ else:
262
+ report(f" [dry-run] correct {entry.label()} ({what})")
263
+
264
+ if live:
265
+ report(f"\nDone: {written} entries written, {updated} corrected.")
266
+ else:
267
+ report("\nDry run - nothing was written. Pass --live to write.")
268
+ return SyncResult(
269
+ written, skipped, new, live, updated, [(e, diff) for e, _, diff in corrections]
270
+ )
@@ -0,0 +1,223 @@
1
+ Metadata-Version: 2.4
2
+ Name: napper-sync
3
+ Version: 0.1.0
4
+ Summary: Unofficial Napper baby-tracker API client, and a sync that imports what the nursery logs in Famly.
5
+ Author: Patrick Wuestling
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/PW-CODING/napper-sync
8
+ Project-URL: Issues, https://github.com/PW-CODING/napper-sync/issues
9
+ Project-URL: Documentation, https://github.com/PW-CODING/napper-sync/tree/main/docs
10
+ Project-URL: Changelog, https://github.com/PW-CODING/napper-sync/blob/main/CHANGELOG.md
11
+ Keywords: napper,famly,baby,baby-tracker,sleep-tracker,nursery,daycare,childcare,kita,krippe,sync,api-client
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Environment :: Console
14
+ Classifier: Intended Audience :: End Users/Desktop
15
+ Classifier: Operating System :: OS Independent
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Programming Language :: Python :: 3.13
20
+ Classifier: Topic :: Home Automation
21
+ Classifier: Topic :: Utilities
22
+ Requires-Python: >=3.11
23
+ Description-Content-Type: text/markdown
24
+ License-File: LICENSE
25
+ Provides-Extra: dev
26
+ Requires-Dist: pytest>=8; extra == "dev"
27
+ Requires-Dist: ruff>=0.6; extra == "dev"
28
+ Dynamic: license-file
29
+
30
+ # napper-sync
31
+
32
+ **Sync what your nursery logs in [Famly](https://famly.co) - naps, meals, diaper
33
+ changes - into the [Napper](https://napper.app) baby tracker, automatically. Also an
34
+ unofficial Python client for the Napper API.**
35
+
36
+ [![CI](https://github.com/PW-CODING/napper-sync/actions/workflows/ci.yml/badge.svg)](https://github.com/PW-CODING/napper-sync/actions/workflows/ci.yml)
37
+ ![Python 3.11+](https://img.shields.io/badge/python-3.11%2B-blue)
38
+ ![License: MIT](https://img.shields.io/badge/license-MIT-green)
39
+
40
+ **Auf Deutsch:** Überträgt automatisch, was die Kita in Famly einträgt - Schlaf, Mahlzeiten
41
+ und Windelwechsel - in die Baby-App Napper. Kein Abtippen am Abend, keine Duplikate, und
42
+ nachträgliche Korrekturen der Kita werden übernommen. Läuft per Zeitplan auf einem
43
+ Raspberry Pi oder jedem anderen Rechner und braucht nur die Python-Standardbibliothek.
44
+ Die Dokumentation ist englisch, Fragen und Issues gern auch auf Deutsch.
45
+
46
+ Napper is a sleep-and-care tracker for babies. Famly is the app many nurseries
47
+ use to tell parents what happened during the day. If your child is in a
48
+ Famly nursery and you track at home with Napper, you end up typing the
49
+ nursery's entries into Napper by hand every evening. This tool does that for
50
+ you, every hour, without creating duplicates.
51
+
52
+ Along the way it documents two APIs that have no public documentation - see
53
+ [`docs/`](https://github.com/PW-CODING/napper-sync/tree/main/docs).
54
+
55
+ > **Disclaimer.** This project is not affiliated with, endorsed by, or supported by
56
+ > Napper AB or Famly ApS. It talks to private, undocumented APIs that the official
57
+ > apps use internally. Those APIs may change or disappear with any app update, and
58
+ > using them may not be covered by the services' terms - check the terms that apply
59
+ > to your account before you decide. Use it with your own account, for your own
60
+ > child, at your own risk; how you use it is your responsibility.
61
+
62
+ ## What it does
63
+
64
+ - **Napper API client** - log in with the e-mailed one-time code, read the
65
+ history, write and delete entries in all eleven categories Napper knows
66
+ (nap, bedtime, woke up, night waking, nursing, bottle, solids, pumping,
67
+ diaper, temperature, medicine), with field validation before anything is sent.
68
+ - **Famly → Napper sync** - reads the nursery's daily events and creates the
69
+ matching Napper entries, marked `(Famly)` so you can tell them apart. When
70
+ the nursery corrects an entry later, the Napper entry follows.
71
+ - **Safe by default** - every write is a dry run unless you pass `--live`;
72
+ duplicate detection makes repeated runs harmless; the Napper rate limit is
73
+ handled with back-off.
74
+ - **File import** - CSV or JSON for anything else you want to get into Napper.
75
+ - **Unattended operation** - systemd timer, Discord notifications on failure
76
+ and on successful imports, tokens refreshed automatically.
77
+
78
+ Standard library only; no runtime dependencies.
79
+
80
+ ## Install
81
+
82
+ ```bash
83
+ pipx install git+https://github.com/PW-CODING/napper-sync
84
+ # or
85
+ pip install --user git+https://github.com/PW-CODING/napper-sync
86
+ ```
87
+
88
+ Requires Python 3.11+.
89
+
90
+ ## Quick start
91
+
92
+ ```bash
93
+ napper-sync login you@example.com # Napper e-mails a code; paste it
94
+ napper-sync babies # find your baby's ID
95
+ napper-sync config init # write ~/.config/napper-sync/config.toml
96
+ # -> put the baby ID into [napper] baby_id
97
+
98
+ napper-sync logs 2026-09-01 2026-09-07 # read a week
99
+ napper-sync add nap 2026-09-18T13:00 2026-09-18T14:20 # dry run
100
+ napper-sync add nap 2026-09-18T13:00 2026-09-18T14:20 --live # write
101
+ napper-sync add diaper 2026-09-18T15:30 --set diaperContent=WET --live
102
+ napper-sync add medicine 2026-09-18T19:00 --comment "Vitamin D" --live
103
+ napper-sync categories # every category, field and allowed value
104
+ ```
105
+
106
+ ### Famly sync
107
+
108
+ ```bash
109
+ napper-sync famly token <VALUE> # from localStorage["famly.accessToken"] in a logged-in browser
110
+ napper-sync famly children # -> [famly] child_id in the config
111
+ napper-sync famly calendar 2026-09-14 2026-09-18 # what the nursery logged
112
+
113
+ napper-sync sync-famly # dry run: shows what would be written
114
+ napper-sync sync-famly --live # write it
115
+ ```
116
+
117
+ To run this hourly on a Raspberry Pi with Discord alerts, see
118
+ [`docs/deployment.md`](https://github.com/PW-CODING/napper-sync/blob/main/docs/deployment.md).
119
+
120
+ ### Importing a file
121
+
122
+ ```bash
123
+ napper-sync sync-file entries.csv --live
124
+ ```
125
+
126
+ Columns `start,end,category,comment` plus any category field as a further
127
+ column; see [`examples/entries.example.csv`](https://github.com/PW-CODING/napper-sync/blob/main/examples/entries.example.csv).
128
+ An optional `id` column pins the Napper ID, so importing a corrected file
129
+ updates the entries instead of duplicating them.
130
+ Categories accept English or German short names (`nap`/`nickerchen`,
131
+ `diaper`/`windel`, `bottle`/`flasche`, …).
132
+
133
+ ## How it stays safe
134
+
135
+ | Risk | Answer |
136
+ |---|---|
137
+ | Writing by accident | every write and delete is a dry run without `--live` |
138
+ | Duplicates from repeated runs | Famly entries get a stable ID derived from child, category and start, so a repeated run rewrites the same entry instead of adding one; everything else is skipped if the same category exists within 10 minutes or overlaps; day markers are one-per-day |
139
+ | Corrections at the nursery | a changed end time, note or diaper content overwrites the Napper entry on the next run; fields added by hand in the app are kept |
140
+ | Hammering the API | 429 responses are parsed and retried with the server's requested delay |
141
+ | Bad data | field names, enum values and ranges are validated locally before a request is built |
142
+ | Silent breakage | Discord message on expired token, failed write, crashed run; unknown Famly event types are listed, never dropped |
143
+
144
+ ## Project layout
145
+
146
+ ```
147
+ src/napper_sync/
148
+ napper.py Napper API client (auth, read, write, delete)
149
+ categories.py what each category expects - the data model
150
+ sync.py Entry, duplicate detection, the sync loop
151
+ sources.py CSV / JSON sources
152
+ famly.py Famly API client
153
+ famly_mapping.py Famly events -> Napper entries
154
+ notify.py Discord webhook
155
+ config.py ~/.config/napper-sync/
156
+ cli.py the napper-sync command
157
+ tools/ research scripts: analyse history, rediscover endpoints, round-trip and correction checks
158
+ examples/ config, data files, systemd units, Windows task
159
+ docs/ what is known about both APIs, and how to deploy
160
+ tests/ pytest suite against an in-process mock of the Napper API
161
+ ```
162
+
163
+ ## Documentation
164
+
165
+ - [docs/napper-api.md](https://github.com/PW-CODING/napper-sync/blob/main/docs/napper-api.md) - endpoints, auth, the log object,
166
+ all categories and fields, the traps (seconds in a field called minutes,
167
+ percent-encoded comments, date-based IDs), and how the write path was found
168
+ - [docs/famly-api.md](https://github.com/PW-CODING/napper-sync/blob/main/docs/famly-api.md) - instances, token, the calendar
169
+ endpoint and its event shapes
170
+ - [docs/deployment.md](https://github.com/PW-CODING/napper-sync/blob/main/docs/deployment.md) - systemd timer, hardening, Discord
171
+
172
+ ## Development
173
+
174
+ ```bash
175
+ git clone https://github.com/PW-CODING/napper-sync && cd napper-sync
176
+ pip install -e ".[dev]"
177
+ pytest
178
+ ruff check . && ruff format --check .
179
+ ```
180
+
181
+ The tests run against an in-process HTTP mock that reproduces the real API's
182
+ quirks (whole-day responses on write, 429 throttling, token refresh). Nothing
183
+ touches a real account.
184
+
185
+ Tools under `tools/` do talk to the real API and are meant for the day an app
186
+ update breaks something: `probe_write_path.py` rediscovers the write endpoint,
187
+ `roundtrip_categories.py` writes one entry per category far in the past,
188
+ checks it and deletes it again, `analyze_categories.py` summarises which
189
+ fields your own history actually uses, and `probe_corrections.py` checks that
190
+ a correction made later at the nursery updates the Napper entry in place.
191
+
192
+ ## Contributing
193
+
194
+ Issues and pull requests are welcome, in English or German. If an app update
195
+ changed something, the most useful report includes the output of
196
+ `tools/roundtrip_categories.py` or of `napper-sync famly calendar` for a day
197
+ with the new event type (with names and IDs removed).
198
+
199
+ ## Related projects
200
+
201
+ Community projects around the same two apps, none of which connects them:
202
+
203
+ - [home-assistant-napper](https://github.com/g-nogueira/home-assistant-napper) -
204
+ read-only Napper integration for Home Assistant
205
+ - [ha-napper](https://github.com/bbruenings/ha-napper) - another Home Assistant
206
+ integration for Napper
207
+ - [napper-export](https://github.com/brittraee/napper-export) - export a child's
208
+ complete Napper history
209
+ - [famly-fetch](https://github.com/jacobbunk/famly-fetch) - download your child's
210
+ photos from Famly
211
+
212
+ ## Acknowledgements
213
+
214
+ Reading the Napper API was first worked out by
215
+ [brittraee/napper-export](https://github.com/brittraee/napper-export) and
216
+ [g-nogueira/home-assistant-napper](https://github.com/g-nogueira/home-assistant-napper);
217
+ the OTP login flow and request headers here follow their findings. Writing,
218
+ deleting, the category model and the Famly side were worked out for this
219
+ project.
220
+
221
+ ## License
222
+
223
+ [MIT](https://github.com/PW-CODING/napper-sync/blob/main/LICENSE)
@@ -0,0 +1,16 @@
1
+ napper_sync/__init__.py,sha256=p4QuIcEqNeFAMc4Yb-8bCwzw1AjuPzKk4KEjGP2GSMM,321
2
+ napper_sync/categories.py,sha256=MscrtWzo-icDp6IcX-6q5kKxwQltXTteCkrMo4rUg-g,10475
3
+ napper_sync/cli.py,sha256=0pXfpurft67nlW60RCbMaJR2FxAgm1dWCXUyEXdhSBg,14051
4
+ napper_sync/config.py,sha256=bO2Y0WLDZNHIMQiwDPEY499i8WARsth30cvKinAhRTw,4428
5
+ napper_sync/famly.py,sha256=8qAnNhpUO1wMagX64zOG2iRRQAlFAMpe6IIk4eOys7U,6028
6
+ napper_sync/famly_mapping.py,sha256=3R8hNC3RjWMFjF6VNs8zuL44wFBa0KinZC-rOKuYp4E,4737
7
+ napper_sync/napper.py,sha256=J11pllwpA5mP5sjoTj_g1DQR9kqAGDQmn_fLsFhpbq0,15773
8
+ napper_sync/notify.py,sha256=hRwWXd5lwzykyUSYccVvlcVKa6bTYullsR9uGp1cCH8,2967
9
+ napper_sync/sources.py,sha256=oSOXdPCvb7X--W48DEkjsYSTJsn7V-BTq92-2PZZEu8,2788
10
+ napper_sync/sync.py,sha256=o5z0bdB4D0kbXdRwZCoRsvuYy3USYEe3e9biQLYFl_8,9589
11
+ napper_sync-0.1.0.dist-info/licenses/LICENSE,sha256=lg52NHZw6W30oWQk2f5ZTJMiD1kHIFFMa7ZyyiWN2Xs,1074
12
+ napper_sync-0.1.0.dist-info/METADATA,sha256=58GDnUVV5CMbmLoSNLEtTwqrDaeBzMix1Q72JDFaBz4,10581
13
+ napper_sync-0.1.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
14
+ napper_sync-0.1.0.dist-info/entry_points.txt,sha256=C0uhMS3uYy0RMNUBudm4q_KjfTWHSN7cMk_3Xd1Qf3o,53
15
+ napper_sync-0.1.0.dist-info/top_level.txt,sha256=Xn-gotaBF0sE_DZjeaWs9hQGgW_T6Z24K-rmwkOqq6o,12
16
+ napper_sync-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.2.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ napper-sync = napper_sync.cli:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Patrick Wuestling
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 @@
1
+ napper_sync