evolutiondb-notes-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.
- evolutiondb_notes_sync-0.1.0.dist-info/METADATA +26 -0
- evolutiondb_notes_sync-0.1.0.dist-info/RECORD +9 -0
- evolutiondb_notes_sync-0.1.0.dist-info/WHEEL +5 -0
- evolutiondb_notes_sync-0.1.0.dist-info/entry_points.txt +2 -0
- evolutiondb_notes_sync-0.1.0.dist-info/top_level.txt +1 -0
- notes_sync/__init__.py +8 -0
- notes_sync/__main__.py +4 -0
- notes_sync/state.py +116 -0
- notes_sync/sync.py +283 -0
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: evolutiondb-notes-sync
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Sync macOS Apple Notes into EvolutionDB long-term memory.
|
|
5
|
+
Author-email: alptekin topal <topal.alptekin@gmail.com>
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/alptekin/evolutiondb
|
|
8
|
+
Project-URL: Repository, https://github.com/alptekin/evolutiondb
|
|
9
|
+
Project-URL: Source, https://github.com/alptekin/evolutiondb/tree/main/client/notes-sync
|
|
10
|
+
Project-URL: Issues, https://github.com/alptekin/evolutiondb/issues
|
|
11
|
+
Keywords: evolutiondb,apple-notes,notes,macos,long-term-memory,agent-memory,mcp
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Environment :: Console
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
16
|
+
Classifier: Operating System :: MacOS
|
|
17
|
+
Classifier: Programming Language :: Python :: 3
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
23
|
+
Classifier: Topic :: Database
|
|
24
|
+
Classifier: Topic :: Office/Business
|
|
25
|
+
Requires-Python: >=3.9
|
|
26
|
+
Requires-Dist: psycopg[binary]>=3.1
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
notes_sync/__init__.py,sha256=8yZmSNOm7ru9akJsY5DCjAdidO1DNzDC1G0Hx8umk1Q,309
|
|
2
|
+
notes_sync/__main__.py,sha256=8Ap-X_Be9dePdOFaAWS6BNHL2kg3FQHkMgQ6Hufk7jg,80
|
|
3
|
+
notes_sync/state.py,sha256=6FyhQkaKpZdkUhAeXeYphP4L5UM2Zvo2miUD6NbXaXk,3906
|
|
4
|
+
notes_sync/sync.py,sha256=ucsw6HAjpc6CyVHUnki2dCpkNRf5z1thF2OwYxnESBw,9131
|
|
5
|
+
evolutiondb_notes_sync-0.1.0.dist-info/METADATA,sha256=K7dXgkbYJNnAZhQIiLOeIgMZJyHTMX9hvsG44GxcC44,1206
|
|
6
|
+
evolutiondb_notes_sync-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
7
|
+
evolutiondb_notes_sync-0.1.0.dist-info/entry_points.txt,sha256=7BtLcAVTqQNBCjERyLKlGu5p363r1CcjijHoJBBSWZU,68
|
|
8
|
+
evolutiondb_notes_sync-0.1.0.dist-info/top_level.txt,sha256=0nZ85TqgUv4qJ9tPTNE6ng0RTvSh1Ny72MNEEBitNyw,11
|
|
9
|
+
evolutiondb_notes_sync-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
notes_sync
|
notes_sync/__init__.py
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
"""evolutiondb-notes-sync — pull macOS Apple Notes content into
|
|
2
|
+
EvolutionDB long-term memory.
|
|
3
|
+
|
|
4
|
+
The first run will prompt for Automation permission so this binary
|
|
5
|
+
can talk to Notes.app over the JavaScript-for-Automation bridge.
|
|
6
|
+
That permission persists; subsequent runs are silent."""
|
|
7
|
+
|
|
8
|
+
__version__ = "0.1.0"
|
notes_sync/__main__.py
ADDED
notes_sync/state.py
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"""Memory backend — mirror of the sibling connector packages."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
import sys
|
|
6
|
+
import time
|
|
7
|
+
from typing import Dict, Optional
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _e(s: str) -> str:
|
|
11
|
+
if not isinstance(s, str):
|
|
12
|
+
s = str(s)
|
|
13
|
+
s = s.replace("\r", " ").replace("\n", " ").replace("\t", " ")
|
|
14
|
+
return s.replace("'", "''")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
WATERMARK_KEY = "notes_state_latest"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _parse_value(raw):
|
|
21
|
+
if isinstance(raw, dict):
|
|
22
|
+
return raw
|
|
23
|
+
if not raw:
|
|
24
|
+
return {}
|
|
25
|
+
try:
|
|
26
|
+
return json.loads(raw)
|
|
27
|
+
except (TypeError, ValueError):
|
|
28
|
+
return {}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class MemoryStore:
|
|
32
|
+
_RECONNECT_ATTEMPTS = 3
|
|
33
|
+
_RECONNECT_BACKOFF_SEC = 0.5
|
|
34
|
+
|
|
35
|
+
def __init__(self, host: str, port: int, user: str, password: str,
|
|
36
|
+
database: str, store: str, namespace: str):
|
|
37
|
+
try:
|
|
38
|
+
import psycopg
|
|
39
|
+
except ImportError as exc:
|
|
40
|
+
raise RuntimeError(
|
|
41
|
+
"evolutiondb-notes-sync requires psycopg. Install "
|
|
42
|
+
"with `pip install psycopg[binary]>=3.1`.") from exc
|
|
43
|
+
|
|
44
|
+
self.psycopg = psycopg
|
|
45
|
+
self._conn_kwargs = dict(
|
|
46
|
+
host=host, port=port, user=user, password=password,
|
|
47
|
+
dbname=database, autocommit=True,
|
|
48
|
+
)
|
|
49
|
+
self.store = store
|
|
50
|
+
self.namespace = namespace
|
|
51
|
+
self.conn = self._connect()
|
|
52
|
+
try:
|
|
53
|
+
with self.conn.cursor() as cur:
|
|
54
|
+
cur.execute(f"CREATE MEMORY STORE {self.store}")
|
|
55
|
+
except Exception:
|
|
56
|
+
pass
|
|
57
|
+
|
|
58
|
+
def _connect(self):
|
|
59
|
+
return self.psycopg.connect(**self._conn_kwargs)
|
|
60
|
+
|
|
61
|
+
def _is_dead(self, exc: BaseException) -> bool:
|
|
62
|
+
return isinstance(exc, (self.psycopg.OperationalError,
|
|
63
|
+
self.psycopg.InterfaceError))
|
|
64
|
+
|
|
65
|
+
def _retry(self, fn):
|
|
66
|
+
last = None
|
|
67
|
+
for attempt in range(self._RECONNECT_ATTEMPTS):
|
|
68
|
+
try:
|
|
69
|
+
with self.conn.cursor() as cur:
|
|
70
|
+
return fn(cur)
|
|
71
|
+
except Exception as exc:
|
|
72
|
+
if not self._is_dead(exc):
|
|
73
|
+
raise
|
|
74
|
+
last = exc
|
|
75
|
+
print(f"[notes-sync] db connection lost "
|
|
76
|
+
f"(attempt {attempt + 1}): {exc}",
|
|
77
|
+
file=sys.stderr, flush=True)
|
|
78
|
+
try:
|
|
79
|
+
self.conn.close()
|
|
80
|
+
except Exception:
|
|
81
|
+
pass
|
|
82
|
+
if attempt + 1 < self._RECONNECT_ATTEMPTS:
|
|
83
|
+
time.sleep(self._RECONNECT_BACKOFF_SEC *
|
|
84
|
+
(attempt + 1))
|
|
85
|
+
try:
|
|
86
|
+
self.conn = self._connect()
|
|
87
|
+
except Exception as reconn:
|
|
88
|
+
last = reconn
|
|
89
|
+
continue
|
|
90
|
+
raise last # type: ignore[misc]
|
|
91
|
+
|
|
92
|
+
def get_watermark_iso(self) -> Optional[str]:
|
|
93
|
+
def run(cur):
|
|
94
|
+
cur.execute(
|
|
95
|
+
f"SELECT mem_value FROM __mem_{self.store} "
|
|
96
|
+
f"WHERE mem_namespace = '{_e(self.namespace)}' "
|
|
97
|
+
f"AND mem_key = '{_e(WATERMARK_KEY)}'")
|
|
98
|
+
rows = cur.fetchall()
|
|
99
|
+
if not rows:
|
|
100
|
+
return None
|
|
101
|
+
v = _parse_value(rows[0][0]).get("modified_at")
|
|
102
|
+
return str(v) if v else None
|
|
103
|
+
return self._retry(run)
|
|
104
|
+
|
|
105
|
+
def set_watermark_iso(self, iso: str) -> None:
|
|
106
|
+
value = json.dumps({"modified_at": iso,
|
|
107
|
+
"saved_at": time.time()})
|
|
108
|
+
self._retry(lambda cur: cur.execute(
|
|
109
|
+
f"MEMORY PUT INTO {self.store} VALUES "
|
|
110
|
+
f"('{_e(self.namespace)}','{_e(WATERMARK_KEY)}','{_e(value)}')"))
|
|
111
|
+
|
|
112
|
+
def put_record(self, key: str, record: Dict) -> None:
|
|
113
|
+
value = json.dumps(record, ensure_ascii=False)
|
|
114
|
+
self._retry(lambda cur: cur.execute(
|
|
115
|
+
f"MEMORY PUT INTO {self.store} VALUES "
|
|
116
|
+
f"('{_e(self.namespace)}','{_e(key)}','{_e(value)}')"))
|
notes_sync/sync.py
ADDED
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
"""
|
|
2
|
+
evolutiondb-notes-sync — talk to Notes.app over JavaScript-for-
|
|
3
|
+
Automation, then store the captured notes in EvolutionDB long-term
|
|
4
|
+
memory.
|
|
5
|
+
|
|
6
|
+
Why JXA, not the SQLite store?
|
|
7
|
+
------------------------------
|
|
8
|
+
Apple Notes encodes the body of each note as gzip + a private
|
|
9
|
+
NotesBody protobuf inside ZICCLOUDSYNCINGOBJECT.ZDATA in
|
|
10
|
+
NoteStore.sqlite. Reverse-engineering that format is a moving
|
|
11
|
+
target across macOS versions. JXA gives us the rendered HTML body
|
|
12
|
+
straight from Notes.app and never breaks across releases.
|
|
13
|
+
|
|
14
|
+
Trade-off: the user has to allow Automation in System Settings →
|
|
15
|
+
Privacy & Security → Automation → Terminal/iTerm → Notes. macOS
|
|
16
|
+
prompts for this on first invocation.
|
|
17
|
+
"""
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import argparse
|
|
21
|
+
import html
|
|
22
|
+
import json
|
|
23
|
+
import os
|
|
24
|
+
import re
|
|
25
|
+
import signal
|
|
26
|
+
import subprocess
|
|
27
|
+
import sys
|
|
28
|
+
import time
|
|
29
|
+
from pathlib import Path
|
|
30
|
+
from typing import Dict, Iterable, List, Optional
|
|
31
|
+
|
|
32
|
+
from . import state as state_mod
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
# JXA evaluated by osascript. Emits one JSON object per line so a
|
|
36
|
+
# Python iterator can process notes without holding everything in
|
|
37
|
+
# memory if there are tens of thousands.
|
|
38
|
+
_JXA = r"""
|
|
39
|
+
ObjC.import('Foundation');
|
|
40
|
+
const Notes = Application('Notes');
|
|
41
|
+
Notes.includeStandardAdditions = false;
|
|
42
|
+
|
|
43
|
+
function isoDate(d) {
|
|
44
|
+
if (!d) return '';
|
|
45
|
+
const f = $.NSISO8601DateFormatter.alloc.init;
|
|
46
|
+
f.formatOptions = $.NSISO8601DateFormatWithInternetDateTime;
|
|
47
|
+
return ObjC.unwrap(f.stringFromDate(d));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function* iterNotes() {
|
|
51
|
+
const ns = Notes.notes();
|
|
52
|
+
for (let i = 0; i < ns.length; i++) {
|
|
53
|
+
try {
|
|
54
|
+
const n = ns[i];
|
|
55
|
+
const folder = (() => {
|
|
56
|
+
try {
|
|
57
|
+
return n.container().name();
|
|
58
|
+
} catch (e) { return ''; }
|
|
59
|
+
})();
|
|
60
|
+
yield {
|
|
61
|
+
id: n.id(),
|
|
62
|
+
name: n.name(),
|
|
63
|
+
body: n.body(),
|
|
64
|
+
folder: folder,
|
|
65
|
+
modified: isoDate(n.modificationDate()),
|
|
66
|
+
created: isoDate(n.creationDate()),
|
|
67
|
+
};
|
|
68
|
+
} catch (e) {
|
|
69
|
+
yield {error: String(e)};
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
for (const n of iterNotes()) {
|
|
75
|
+
console.log(JSON.stringify(n));
|
|
76
|
+
}
|
|
77
|
+
"""
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _html_to_text(body_html: str) -> str:
|
|
81
|
+
"""Cheap HTML→text. Apple Notes body uses simple tags
|
|
82
|
+
(h1/p/div/br/li). We strip tags, decode entities, collapse
|
|
83
|
+
whitespace, normalise line breaks."""
|
|
84
|
+
if not body_html:
|
|
85
|
+
return ""
|
|
86
|
+
s = body_html
|
|
87
|
+
s = re.sub(r"</?(?:br|p|div|h[1-6]|li)[^>]*>", "\n", s,
|
|
88
|
+
flags=re.I)
|
|
89
|
+
s = re.sub(r"<[^>]+>", " ", s)
|
|
90
|
+
s = html.unescape(s)
|
|
91
|
+
s = re.sub(r"[ \t]+", " ", s)
|
|
92
|
+
s = re.sub(r"\n[ \t]+", "\n", s)
|
|
93
|
+
s = re.sub(r"\n{3,}", "\n\n", s)
|
|
94
|
+
return s.strip()
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def iter_notes() -> Iterable[Dict]:
|
|
98
|
+
"""Spawn osascript and stream JSON notes line by line."""
|
|
99
|
+
try:
|
|
100
|
+
proc = subprocess.Popen(
|
|
101
|
+
["/usr/bin/osascript", "-l", "JavaScript", "-e", _JXA],
|
|
102
|
+
stdout=subprocess.PIPE, stderr=subprocess.PIPE,
|
|
103
|
+
text=True, bufsize=1)
|
|
104
|
+
except FileNotFoundError:
|
|
105
|
+
print("[notes-sync] /usr/bin/osascript not found — Notes "
|
|
106
|
+
"sync is macOS-only.", file=sys.stderr, flush=True)
|
|
107
|
+
return
|
|
108
|
+
|
|
109
|
+
assert proc.stdout is not None
|
|
110
|
+
try:
|
|
111
|
+
for line in proc.stdout:
|
|
112
|
+
line = line.strip()
|
|
113
|
+
if not line:
|
|
114
|
+
continue
|
|
115
|
+
try:
|
|
116
|
+
obj = json.loads(line)
|
|
117
|
+
except json.JSONDecodeError:
|
|
118
|
+
continue
|
|
119
|
+
if "error" in obj:
|
|
120
|
+
print(f"[notes-sync] note skipped: {obj['error']}",
|
|
121
|
+
file=sys.stderr, flush=True)
|
|
122
|
+
continue
|
|
123
|
+
yield obj
|
|
124
|
+
finally:
|
|
125
|
+
try:
|
|
126
|
+
proc.stdout.close()
|
|
127
|
+
except Exception:
|
|
128
|
+
pass
|
|
129
|
+
rc = proc.wait(timeout=600)
|
|
130
|
+
if rc != 0 and proc.stderr is not None:
|
|
131
|
+
err = proc.stderr.read().strip()
|
|
132
|
+
if err:
|
|
133
|
+
print(f"[notes-sync] osascript stderr: {err[:400]}",
|
|
134
|
+
file=sys.stderr, flush=True)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
# ---------------------------------------------------------------- #
|
|
138
|
+
def _load_dotenv(path: Path) -> None:
|
|
139
|
+
if not path.exists():
|
|
140
|
+
return
|
|
141
|
+
for raw in path.read_text(encoding="utf-8").splitlines():
|
|
142
|
+
line = raw.strip()
|
|
143
|
+
if not line or line.startswith("#") or "=" not in line:
|
|
144
|
+
continue
|
|
145
|
+
k, _, v = line.partition("=")
|
|
146
|
+
k, v = k.strip(), v.strip().strip('"').strip("'")
|
|
147
|
+
if v:
|
|
148
|
+
os.environ.setdefault(k, v)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
class Config:
|
|
152
|
+
def __init__(self):
|
|
153
|
+
self.evosql_host = os.environ.get("EVOSQL_HOST", "127.0.0.1")
|
|
154
|
+
self.evosql_port = int(os.environ.get("EVOSQL_PORT", "5433"))
|
|
155
|
+
self.evosql_user = os.environ.get("EVOSQL_USER", "admin")
|
|
156
|
+
self.evosql_pass = os.environ.get("EVOSQL_PASSWORD", "admin")
|
|
157
|
+
self.evosql_db = os.environ.get("EVOSQL_DATABASE", "evosql")
|
|
158
|
+
self.user_id = os.environ.get("MCP_USER_ID",
|
|
159
|
+
"default_user")
|
|
160
|
+
self.store = os.environ.get("NOTES_MEMORY_STORE",
|
|
161
|
+
"mcp_mem")
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _build_record(n: Dict) -> Optional[Dict]:
|
|
165
|
+
title = (n.get("name") or "").strip() or "(untitled)"
|
|
166
|
+
text = _html_to_text(n.get("body") or "")
|
|
167
|
+
if not text and title == "(untitled)":
|
|
168
|
+
return None
|
|
169
|
+
modified = n.get("modified") or n.get("created") or ""
|
|
170
|
+
return {
|
|
171
|
+
"fact": f"Note \"{title}\": {text[:240]}",
|
|
172
|
+
"source": "notes",
|
|
173
|
+
"kind": "note",
|
|
174
|
+
"title": title,
|
|
175
|
+
"text": text,
|
|
176
|
+
"folder": n.get("folder") or "",
|
|
177
|
+
"note_id": n.get("id") or "",
|
|
178
|
+
"modified_at": modified,
|
|
179
|
+
"created_at": n.get("created") or "",
|
|
180
|
+
"tags": ["notes", "apple-notes"],
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _note_key(n: Dict) -> str:
|
|
185
|
+
raw_id = (n.get("note_id") or "").replace("/", "_")
|
|
186
|
+
return f"note_{raw_id}"
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def sync_once(cfg: Config, *, since_iso: Optional[str],
|
|
190
|
+
dry_run: bool = False) -> Dict[str, int]:
|
|
191
|
+
counters = {"notes": 0, "skipped": 0, "errors": 0}
|
|
192
|
+
store: Optional[state_mod.MemoryStore] = None
|
|
193
|
+
if not dry_run:
|
|
194
|
+
store = state_mod.MemoryStore(
|
|
195
|
+
host=cfg.evosql_host, port=cfg.evosql_port,
|
|
196
|
+
user=cfg.evosql_user, password=cfg.evosql_pass,
|
|
197
|
+
database=cfg.evosql_db, store=cfg.store,
|
|
198
|
+
namespace=cfg.user_id,
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
wm = (store.get_watermark_iso() if store else None)
|
|
202
|
+
floor = wm or since_iso
|
|
203
|
+
highest = floor or ""
|
|
204
|
+
|
|
205
|
+
try:
|
|
206
|
+
for raw in iter_notes():
|
|
207
|
+
rec = _build_record(raw)
|
|
208
|
+
if not rec:
|
|
209
|
+
counters["skipped"] += 1
|
|
210
|
+
continue
|
|
211
|
+
if floor and rec["modified_at"] <= floor:
|
|
212
|
+
continue
|
|
213
|
+
if store:
|
|
214
|
+
store.put_record(_note_key(rec), rec)
|
|
215
|
+
counters["notes"] += 1
|
|
216
|
+
if rec["modified_at"] > highest:
|
|
217
|
+
highest = rec["modified_at"]
|
|
218
|
+
except Exception as exc: # noqa: BLE001
|
|
219
|
+
print(f"[notes-sync] read failed: {exc}",
|
|
220
|
+
file=sys.stderr, flush=True)
|
|
221
|
+
counters["errors"] += 1
|
|
222
|
+
|
|
223
|
+
if store and highest and highest != (wm or ""):
|
|
224
|
+
store.set_watermark_iso(highest)
|
|
225
|
+
return counters
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
_stop = False
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def _install_signal_handlers() -> None:
|
|
232
|
+
def _handler(_signum, _frame):
|
|
233
|
+
global _stop
|
|
234
|
+
_stop = True
|
|
235
|
+
print("[notes-sync] stop requested",
|
|
236
|
+
file=sys.stderr, flush=True)
|
|
237
|
+
for s in (signal.SIGTERM, signal.SIGINT):
|
|
238
|
+
try:
|
|
239
|
+
signal.signal(s, _handler)
|
|
240
|
+
except (ValueError, OSError):
|
|
241
|
+
pass
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def main(argv: Optional[List[str]] = None) -> int:
|
|
245
|
+
parser = argparse.ArgumentParser(prog="evolutiondb-notes-sync",
|
|
246
|
+
description="Sync macOS Apple Notes into EvolutionDB.")
|
|
247
|
+
parser.add_argument("--once", action="store_true")
|
|
248
|
+
parser.add_argument("--interval", type=int)
|
|
249
|
+
parser.add_argument("--since",
|
|
250
|
+
default="",
|
|
251
|
+
help="Optional ISO floor for first run (e.g. 2026-01-01T00:00:00Z). "
|
|
252
|
+
"Empty means import everything.")
|
|
253
|
+
parser.add_argument("--dry-run", action="store_true")
|
|
254
|
+
parser.add_argument("--env-file", default=".env")
|
|
255
|
+
args = parser.parse_args(argv)
|
|
256
|
+
|
|
257
|
+
_load_dotenv(Path(args.env_file).expanduser())
|
|
258
|
+
cfg = Config()
|
|
259
|
+
_install_signal_handlers()
|
|
260
|
+
|
|
261
|
+
def run_pass() -> int:
|
|
262
|
+
try:
|
|
263
|
+
counts = sync_once(cfg, since_iso=args.since or None,
|
|
264
|
+
dry_run=args.dry_run)
|
|
265
|
+
print(json.dumps({"ok": True, **counts}), flush=True)
|
|
266
|
+
return 0
|
|
267
|
+
except Exception as exc: # noqa: BLE001
|
|
268
|
+
print(json.dumps({"ok": False, "error": str(exc)}),
|
|
269
|
+
flush=True)
|
|
270
|
+
return 4
|
|
271
|
+
|
|
272
|
+
if args.once or not args.interval:
|
|
273
|
+
return run_pass()
|
|
274
|
+
|
|
275
|
+
interval = max(60, int(args.interval))
|
|
276
|
+
rc = 0
|
|
277
|
+
while not _stop:
|
|
278
|
+
rc = run_pass()
|
|
279
|
+
for _ in range(interval):
|
|
280
|
+
if _stop:
|
|
281
|
+
break
|
|
282
|
+
time.sleep(1)
|
|
283
|
+
return rc
|