privatecontext 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.
@@ -0,0 +1,3 @@
1
+ """PrivateContext — lightweight, local-first activity tracker (v0)."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,350 @@
1
+ """Event-driven capture loop for PrivateContext.
2
+
3
+ Live mode:
4
+ * active-window watcher, once per second, per-OS with stdlib only
5
+ (Linux: xprop/X11, macOS: osascript, Windows: ctypes). Degrades to a
6
+ warning when the platform hooks are unavailable.
7
+ * click watcher via pynput — logs x, y, window, url, timestamp.
8
+ Key *contents* are never recorded; the keyboard listener exists only
9
+ to timestamp activity for AFK detection.
10
+ * AFK watcher: no input for 180 s -> 'afk' event; next input -> 'back'.
11
+ * URL capture: best-effort active-tab URL (macOS browsers via AppleScript
12
+ in v0); query params and fragments are ALWAYS stripped before storage.
13
+ * Screenshots: OFF by default. With --screenshots, the primary monitor is
14
+ captured on app-switch / URL-change only, downscaled to 640 px wide and
15
+ stored as JPEG (quality 70, Pillow) under shots/YYYY-MM-DD/.
16
+
17
+ Simulate mode (--simulate): writes a realistic synthetic workday straight
18
+ into the DB. No OS hooks, no pynput, no mss — runs anywhere, including
19
+ headless CI. Used to test the whole pipeline end to end.
20
+ """
21
+ from __future__ import annotations
22
+
23
+ import ctypes
24
+ import platform
25
+ import re
26
+ import shutil
27
+ import subprocess
28
+ import sys
29
+ import threading
30
+ import time
31
+ from datetime import date, datetime, timedelta
32
+ from pathlib import Path
33
+
34
+ from .db import connect, data_dir, init_db, log_event, strip_url
35
+
36
+ POLL_S = 1.0
37
+ IDLE_AFTER_S = 180
38
+ SHOT_MAX_W = 640
39
+
40
+ _warned: set[str] = set()
41
+
42
+
43
+ def warn_once(key: str, msg: str) -> None:
44
+ if key not in _warned:
45
+ _warned.add(key)
46
+ print(f"[privatecontext] {msg}", file=sys.stderr)
47
+
48
+
49
+ def _run(cmd: list[str], timeout: int = 2) -> str:
50
+ try:
51
+ r = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)
52
+ return r.stdout.strip() if r.returncode == 0 else ""
53
+ except Exception:
54
+ return ""
55
+
56
+
57
+ # ---------------------------------------------------------------------------
58
+ # Active window, per OS (stdlib only)
59
+ # ---------------------------------------------------------------------------
60
+
61
+ def _active_window_linux() -> dict | None:
62
+ if not shutil.which("xprop"):
63
+ warn_once("xprop", "'xprop' not found: window watcher disabled "
64
+ "(v0 supports X11 only, not Wayland)")
65
+ return None
66
+ root = _run(["xprop", "-root", "_NET_ACTIVE_WINDOW"])
67
+ m = re.search(r"0x[0-9a-fA-F]+", root)
68
+ if not m or m.group(0) == "0x0":
69
+ return None
70
+ out = _run(["xprop", "-id", m.group(0), "WM_CLASS", "_NET_WM_NAME"])
71
+ app_m = re.search(r'WM_CLASS\(STRING\) = "(?:[^"]+)", "([^"]+)"', out)
72
+ title_m = re.search(r'_NET_WM_NAME\(UTF8_STRING\) = "(.*)"', out)
73
+ return {"app": app_m.group(1) if app_m else None,
74
+ "title": title_m.group(1) if title_m else None}
75
+
76
+
77
+ def _active_window_macos() -> dict | None:
78
+ app = _run(["osascript", "-e",
79
+ 'tell application "System Events" to get name of first '
80
+ 'process whose frontmost is true'])
81
+ if not app:
82
+ warn_once("macos", "osascript failed: window watcher disabled")
83
+ return None
84
+ title = _run(["osascript", "-e",
85
+ 'tell application "System Events" to tell '
86
+ '(first process whose frontmost is true) to get name of front window'])
87
+ return {"app": app or None, "title": title or None}
88
+
89
+
90
+ def _active_window_windows() -> dict | None:
91
+ try:
92
+ user32 = ctypes.windll.user32 # type: ignore[attr-defined]
93
+ except Exception:
94
+ warn_once("win", "ctypes.windll unavailable: window watcher disabled")
95
+ return None
96
+ hwnd = user32.GetForegroundWindow()
97
+ if not hwnd:
98
+ return None
99
+ tbuf = ctypes.create_unicode_buffer(512)
100
+ cbuf = ctypes.create_unicode_buffer(256)
101
+ user32.GetWindowTextW(hwnd, tbuf, 512)
102
+ user32.GetClassNameW(hwnd, cbuf, 256)
103
+ return {"app": cbuf.value or None, "title": tbuf.value or None}
104
+
105
+
106
+ def active_window() -> dict | None:
107
+ """Return {'app': ..., 'title': ...} or None. Never raises."""
108
+ sysname = platform.system()
109
+ try:
110
+ if sysname == "Linux":
111
+ return _active_window_linux()
112
+ if sysname == "Darwin":
113
+ return _active_window_macos()
114
+ if sysname == "Windows":
115
+ return _active_window_windows()
116
+ warn_once("os", f"unsupported OS ({sysname}): window watcher disabled")
117
+ return None
118
+ except Exception as e: # a watcher must never kill the loop
119
+ warn_once("watcher-err", f"window watcher error: {e}")
120
+ return None
121
+
122
+
123
+ # ---------------------------------------------------------------------------
124
+ # Browser URL (best effort; always stripped of query/fragment by log_event)
125
+ # ---------------------------------------------------------------------------
126
+
127
+ _MAC_BROWSERS = {
128
+ "Google Chrome": 'tell application "Google Chrome" to get URL of active tab of front window',
129
+ "Safari": 'tell application "Safari" to get URL of front document',
130
+ }
131
+
132
+
133
+ def browser_url(app: str | None, title: str | None) -> str | None:
134
+ if platform.system() != "Darwin" or app not in _MAC_BROWSERS:
135
+ return None # v0: no browser extension yet; titles still logged
136
+ url = _run(["osascript", "-e", _MAC_BROWSERS[app]])
137
+ return url or None
138
+
139
+
140
+ # ---------------------------------------------------------------------------
141
+ # Screenshots (opt-in): downscaled JPEG via Pillow
142
+ # ---------------------------------------------------------------------------
143
+
144
+ JPEG_QUALITY = 70
145
+
146
+
147
+ def _downscale_rgb(rgb: bytes, w: int, h: int, target_w: int = SHOT_MAX_W):
148
+ if w <= target_w:
149
+ return rgb, w, h
150
+ scale = target_w / w
151
+ tw, th = target_w, max(1, int(h * scale))
152
+ out = bytearray(tw * th * 3)
153
+ for y in range(th):
154
+ sy = min(h - 1, int(y / scale))
155
+ for x in range(tw):
156
+ sx = min(w - 1, int(x / scale))
157
+ si, di = (sy * w + sx) * 3, (y * tw + x) * 3
158
+ out[di:di + 3] = rgb[si:si + 3]
159
+ return bytes(out), tw, th
160
+
161
+
162
+ def _write_jpeg(path: Path, rgb: bytes, w: int, h: int,
163
+ quality: int = JPEG_QUALITY) -> None:
164
+ from PIL import Image
165
+ Image.frombytes("RGB", (w, h), rgb).save(path, "JPEG", quality=quality)
166
+
167
+
168
+ def take_screenshot(ts: float) -> str | None:
169
+ """Capture primary monitor, downscaled. Returns path relative to data dir."""
170
+ try:
171
+ import mss
172
+ except Exception as e:
173
+ warn_once("mss", f"mss unavailable ({e}): screenshots disabled")
174
+ return None
175
+ try:
176
+ from PIL import Image # noqa: F401 (import check; used in _write_jpeg)
177
+ except Exception as e:
178
+ warn_once("pil", f"Pillow unavailable ({e}): screenshots disabled")
179
+ return None
180
+ try:
181
+ with mss.mss() as sct:
182
+ mon = sct.monitors[1] if len(sct.monitors) > 1 else sct.monitors[0]
183
+ shot = sct.grab(mon)
184
+ rgb, w, h = _downscale_rgb(shot.rgb, shot.width, shot.height)
185
+ dt = datetime.fromtimestamp(ts).astimezone()
186
+ rel = Path("shots") / dt.strftime("%Y-%m-%d") / (dt.strftime("%H%M%S") + ".jpg")
187
+ p = data_dir() / rel
188
+ p.parent.mkdir(parents=True, exist_ok=True)
189
+ _write_jpeg(p, rgb, w, h)
190
+ return str(rel)
191
+ except Exception as e:
192
+ warn_once("shot-err", f"screenshot failed: {e}")
193
+ return None
194
+
195
+
196
+ # ---------------------------------------------------------------------------
197
+ # Input watchers (pynput, imported lazily so --simulate never needs it)
198
+ # ---------------------------------------------------------------------------
199
+
200
+ class WatchState:
201
+ def __init__(self) -> None:
202
+ self.lock = threading.Lock()
203
+ self.last_input = time.time()
204
+
205
+
206
+ def start_input_watchers(con, state: WatchState) -> list:
207
+ """Mouse + keyboard listeners. The keyboard listener records a timestamp
208
+ only — key contents are never stored, anywhere, ever."""
209
+ try:
210
+ from pynput import keyboard, mouse
211
+ except Exception as e:
212
+ warn_once("pynput", f"pynput unavailable ({e}): click + AFK watchers disabled")
213
+ return []
214
+
215
+ def touch() -> None:
216
+ with state.lock:
217
+ state.last_input = time.time()
218
+
219
+ def on_click(x, y, button, pressed) -> None:
220
+ if not pressed:
221
+ return
222
+ touch()
223
+ w = active_window() or {}
224
+ url = browser_url(w.get("app"), w.get("title"))
225
+ with state.lock: # sqlite handle is shared with the main loop
226
+ log_event(con, "click", app=w.get("app"), title=w.get("title"),
227
+ url=url, x=int(x), y=int(y))
228
+
229
+ def on_press(key) -> None:
230
+ touch() # timestamp only — key contents are never recorded
231
+
232
+ ml = mouse.Listener(on_click=on_click)
233
+ kl = keyboard.Listener(on_press=on_press)
234
+ ml.daemon = kl.daemon = True
235
+ ml.start()
236
+ kl.start()
237
+ return [ml, kl]
238
+
239
+
240
+ # ---------------------------------------------------------------------------
241
+ # Live capture loop
242
+ # ---------------------------------------------------------------------------
243
+
244
+ def run_capture(db_path: str | None = None, screenshots: bool = False) -> None:
245
+ con = connect(db_path)
246
+ init_db(con)
247
+ target = db_path or "default location"
248
+ print(f"[privatecontext] capturing -> {target} "
249
+ f"(screenshots={'ON' if screenshots else 'off'}) — Ctrl-C to stop")
250
+ state = WatchState()
251
+ listeners = start_input_watchers(con, state)
252
+
253
+ last_sig: tuple = (None, None)
254
+ last_url: str | None = None
255
+ afk, afk_start = False, 0.0
256
+ try:
257
+ while True:
258
+ now = time.time()
259
+ with state.lock:
260
+ idle = now - state.last_input
261
+ if listeners and not afk and idle > IDLE_AFTER_S:
262
+ afk, afk_start = True, now
263
+ log_event(con, "afk")
264
+ print("[privatecontext] idle: AFK")
265
+ elif afk and idle <= IDLE_AFTER_S:
266
+ afk = False
267
+ log_event(con, "back", duration_ms=int((now - afk_start) * 1000))
268
+ print(f"[privatecontext] back after {int(now - afk_start)}s idle")
269
+
270
+ w = active_window() or {}
271
+ sig = (w.get("app"), w.get("title"))
272
+ if any(sig) and sig != last_sig:
273
+ url = browser_url(w.get("app"), w.get("title"))
274
+ log_event(con, "window", app=sig[0], title=sig[1], url=url)
275
+ if screenshots and (sig[0] != last_sig[0] or url != last_url):
276
+ rel = take_screenshot(now)
277
+ log_event(con, "shot", app=sig[0], title=sig[1], url=url)
278
+ # shot path is derivable from ts: shots/YYYY-MM-DD/HHMMSS.jpg
279
+ _ = rel
280
+ last_sig, last_url = sig, url
281
+ time.sleep(POLL_S)
282
+ except KeyboardInterrupt:
283
+ print("\n[privatecontext] stopped.")
284
+ finally:
285
+ for lst in listeners:
286
+ lst.stop()
287
+
288
+
289
+ # ---------------------------------------------------------------------------
290
+ # Simulate mode: a realistic synthetic workday, zero OS hooks
291
+ # ---------------------------------------------------------------------------
292
+
293
+ def simulate(db_path: str | None = None, day: date | None = None) -> None:
294
+ import random
295
+
296
+ rng = random.Random(20261013)
297
+ con = connect(db_path)
298
+ init_db(con)
299
+ day = day or date.today()
300
+ base = datetime(day.year, day.month, day.day).astimezone() # local midnight
301
+
302
+ def ts(h: int, m: int, s: int = 0) -> float:
303
+ return (base + timedelta(hours=h, minutes=m, seconds=s)).timestamp()
304
+
305
+ # (start, end, app, title, url, clicks_per_min); app=None -> AFK block
306
+ blocks = [
307
+ ((9, 2), (9, 24), "Slack", "OIT Help Desk — #general", None, 3),
308
+ ((9, 24), (11, 38), "Google Chrome",
309
+ "INC0012345 — Printer jam, Hill Library | ServiceNow",
310
+ "https://ncsu.service-now.com/nav_to.do?uri=incident.do&sys_id=9f3a1c", 7),
311
+ ((11, 38), (11, 55), "Google Chrome",
312
+ "eduroam keeps dropping on Dell 5420 — Ask Ubuntu",
313
+ "https://askubuntu.com/questions/12345/eduroam-keeps-dropping?utm_source=x&x=1", 5),
314
+ ((11, 55), (12, 8), "Code", "privatecontext — capture.py", None, 9),
315
+ ((12, 8), (12, 52), None, None, None, 0), # lunch
316
+ ((12, 52), (14, 5), "Google Chrome",
317
+ "INC0012371 — VPN split tunnel question | ServiceNow",
318
+ "https://ncsu.service-now.com/incident.do?sys_id=77aa01&sysparm_nostack=true", 6),
319
+ ((14, 5), (14, 50), "Zoom", "OIT weekly standup", None, 1),
320
+ ((14, 50), (16, 20), "Code", "privatecontext — dashboard.py", None, 8),
321
+ ((16, 20), (16, 35), "Terminal", "zsh — ~/workspace/privatecontext", None, 4),
322
+ ((16, 35), (17, 10), "Spotify", "Deep Focus", None, 1),
323
+ ]
324
+
325
+ n_win = n_click = n_afk = 0
326
+ for (sh, sm), (eh, em), app, title, url, rate in blocks:
327
+ t0, t1 = ts(sh, sm), ts(eh, em)
328
+ if app is None:
329
+ log_event(con, "afk", ts=t0, commit=False)
330
+ log_event(con, "back", ts=t1, duration_ms=int((t1 - t0) * 1000), commit=False)
331
+ n_afk += 1
332
+ continue
333
+ log_event(con, "window", ts=t0, app=app, title=title, url=url, commit=False)
334
+ n_win += 1
335
+ if rng.random() < 0.7: # mid-block title refinement, like a ticket updating
336
+ tmid = t0 + (t1 - t0) * rng.uniform(0.3, 0.7)
337
+ log_event(con, "window", ts=tmid, app=app, title=title,
338
+ url=url, commit=False)
339
+ n_win += 1
340
+ if rate:
341
+ t = t0 + rng.uniform(20, 90)
342
+ while t < t1 - 10:
343
+ log_event(con, "click", ts=t, app=app, title=title, url=url,
344
+ x=rng.randint(100, 1800), y=rng.randint(80, 950),
345
+ commit=False)
346
+ n_click += 1
347
+ t += (60.0 / rate) * rng.uniform(0.5, 1.5)
348
+ con.commit()
349
+ print(f"[privatecontext] simulated {day.isoformat()}: "
350
+ f"{n_win} window events, {n_click} clicks, {n_afk} AFK periods")
privatecontext/cli.py ADDED
@@ -0,0 +1,228 @@
1
+ """pctx — command line interface for PrivateContext."""
2
+ from __future__ import annotations
3
+
4
+ import argparse
5
+ import os
6
+ import sqlite3
7
+ import sys
8
+ from datetime import date
9
+ from pathlib import Path
10
+
11
+ from . import __version__
12
+ from .capture import run_capture, simulate
13
+ from .dashboard import serve
14
+ from .db import connect, counts, default_db_path, init_db
15
+ from .importers import (RefusedError, import_activitywatch, import_browser,
16
+ import_csv, import_json, import_screenpipe)
17
+ from .summarize import DEFAULT_MODEL, summarize_day
18
+ from .tagging import extract_tags
19
+
20
+
21
+ def _resolve_db(path: str) -> str:
22
+ return os.path.abspath(path)
23
+
24
+
25
+ def cmd_capture(args: argparse.Namespace) -> int:
26
+ db = _resolve_db(args.db) if args.db else None
27
+ if args.simulate:
28
+ simulate(db)
29
+ else:
30
+ run_capture(db, screenshots=args.screenshots)
31
+ return 0
32
+
33
+
34
+ def cmd_dashboard(args: argparse.Namespace) -> int:
35
+ db = _resolve_db(args.db) if args.db else None
36
+ serve(db, port=args.port)
37
+ return 0
38
+
39
+
40
+ def cmd_export(args: argparse.Namespace) -> int:
41
+ if not args.scrubbed:
42
+ print("v0 only supports scrubbed exports: `pctx export --scrubbed`",
43
+ file=sys.stderr)
44
+ return 2
45
+ src_path = _resolve_db(args.db) if args.db else default_db_path()
46
+ if not Path(src_path).exists():
47
+ print(f"no database at {src_path} — run `pctx capture` first",
48
+ file=sys.stderr)
49
+ return 1
50
+ out = args.out or f"./privatecontext-export-{date.today().isoformat()}.db"
51
+ out_path = os.path.abspath(out)
52
+ if Path(out_path).exists():
53
+ os.remove(out_path)
54
+
55
+ # Build the export through the real schema so FTS triggers fire on copy.
56
+ dst = sqlite3.connect(out_path)
57
+ schema = Path(__file__).resolve().parent / "schema.sql"
58
+ if not schema.exists(): # running from a source checkout
59
+ schema = Path(__file__).resolve().parent.parent / "schema.sql"
60
+ dst.executescript(schema.read_text())
61
+ # Quote the path for ATTACH: sqlite string literal, ''-escaped.
62
+ lit = "'" + src_path.replace("'", "''") + "'"
63
+ dst.execute(f"ATTACH DATABASE {lit} AS src")
64
+ # Column-explicit copy so pre-tags databases still export cleanly.
65
+ src_cols = [r[1] for r in dst.execute("PRAGMA src.table_info(events)")]
66
+ cols = ["ts", "type", "app", "title", "url", "x", "y", "duration_ms"]
67
+ if "tags" in src_cols:
68
+ cols.append("tags")
69
+ col_list = ",".join(cols)
70
+ dst.execute(f"INSERT INTO events({col_list}) SELECT {col_list} FROM src.events"
71
+ " WHERE type != 'shot'")
72
+ dst.execute("INSERT INTO days SELECT * FROM src.days")
73
+ dst.execute("INSERT INTO kv SELECT * FROM src.kv")
74
+ dst.commit()
75
+ n = dst.execute("SELECT COUNT(*) FROM events").fetchone()[0]
76
+ dst.close()
77
+ print(f"[privatecontext] exported {n} scrubbed events -> {out_path}")
78
+ print("screenshots and 'shot' events were excluded.")
79
+ return 0
80
+
81
+
82
+ def cmd_summarize(args: argparse.Namespace) -> int:
83
+ from datetime import date as _date
84
+ db = _resolve_db(args.db) if args.db else None
85
+ return summarize_day(db, args.date or _date.today().isoformat(),
86
+ model=args.model, dry_run=args.dry_run)
87
+
88
+
89
+ def cmd_mcp(args: argparse.Namespace) -> int:
90
+ try:
91
+ from .mcp_server import main as mcp_main
92
+ except ImportError:
93
+ print("the 'mcp' package is required for `pctx mcp`:\n"
94
+ " pip install 'privatecontext[mcp]' (or: pip install mcp)",
95
+ file=sys.stderr)
96
+ return 1
97
+ db = _resolve_db(args.db) if args.db else None
98
+ return mcp_main(db)
99
+
100
+
101
+ def build_parser() -> argparse.ArgumentParser:
102
+ ap = argparse.ArgumentParser(
103
+ prog="pctx",
104
+ description=f"PrivateContext v{__version__} — lightweight local activity tracker.")
105
+ ap.add_argument("--db", default=None,
106
+ help=f"database path (default: {default_db_path()})")
107
+ sub = ap.add_subparsers(dest="cmd", required=True)
108
+
109
+ c = sub.add_parser("capture", help="run the event capture loop")
110
+ c.add_argument("--simulate", action="store_true",
111
+ help="write a synthetic workday instead of watching the OS")
112
+ c.add_argument("--screenshots", action="store_true",
113
+ help="capture downscaled screenshots on app/URL switch (OFF by default)")
114
+ c.set_defaults(func=cmd_capture)
115
+
116
+ d = sub.add_parser("dashboard", help="serve the local dashboard")
117
+ d.add_argument("--port", type=int, default=18790)
118
+ d.set_defaults(func=cmd_dashboard)
119
+
120
+ e = sub.add_parser("export", help="export a shareable copy of the database")
121
+ e.add_argument("--scrubbed", action="store_true",
122
+ help="exclude screenshots and shot events (required in v0)")
123
+ e.add_argument("--out", default=None, help="output .db path")
124
+ e.set_defaults(func=cmd_export)
125
+
126
+ s = sub.add_parser("summarize", help="summarize a day with a local Ollama model")
127
+ s.add_argument("--date", default=None, help="YYYY-MM-DD (default: today)")
128
+ s.add_argument("--model", default=DEFAULT_MODEL,
129
+ help=f"Ollama model (default: {DEFAULT_MODEL})")
130
+ s.add_argument("--dry-run", action="store_true",
131
+ help="print the condensed digest without calling Ollama")
132
+ s.set_defaults(func=cmd_summarize)
133
+
134
+ m = sub.add_parser("mcp", help="run the MCP server over stdio")
135
+ m.set_defaults(func=cmd_mcp)
136
+
137
+ r = sub.add_parser("retag", help="backfill auto-tags on untagged events")
138
+ r.add_argument("--since", default=None,
139
+ help="only retag events on/after YYYY-MM-DD")
140
+ r.set_defaults(func=cmd_retag)
141
+
142
+ i = sub.add_parser("import", help="import history from browsers or other recorders")
143
+ i.add_argument("--browser", choices=["chrome", "edge", "firefox"], default=None,
144
+ help="import this browser's history")
145
+ i.add_argument("--profile", default=None,
146
+ help="path to the browser profile History / places.sqlite file")
147
+ i.add_argument("--activitywatch", default=None, metavar="PATH",
148
+ help="path to an ActivityWatch SQLite database")
149
+ i.add_argument("--screenpipe", default=None, metavar="PATH",
150
+ help="path to a Screenpipe SQLite database")
151
+ i.add_argument("--csv", default=None, metavar="PATH",
152
+ help="path to a CSV export (columns: timestamp, type, "
153
+ "app, title, url)")
154
+ i.add_argument("--json", default=None, metavar="PATH",
155
+ help="path to a JSON export (array of event objects)")
156
+ i.add_argument("--dry-run", action="store_true",
157
+ help="print what would be imported without writing anything")
158
+ i.set_defaults(func=cmd_import)
159
+ return ap
160
+
161
+
162
+ def cmd_import(args: argparse.Namespace) -> int:
163
+ sources = [s for s in (args.browser, args.activitywatch, args.screenpipe,
164
+ args.csv, args.json) if s]
165
+ if len(sources) != 1:
166
+ print("pick exactly one source: --browser chrome|edge|firefox, "
167
+ "--activitywatch PATH, --screenpipe PATH, --csv PATH, or "
168
+ "--json PATH", file=sys.stderr)
169
+ return 2
170
+ db = _resolve_db(args.db) if args.db else default_db_path()
171
+ con = connect(db)
172
+ init_db(con)
173
+ try:
174
+ if args.browser:
175
+ return import_browser(con, args.browser, profile=args.profile,
176
+ dry_run=args.dry_run)
177
+ if args.activitywatch:
178
+ return import_activitywatch(con, args.activitywatch,
179
+ dry_run=args.dry_run)
180
+ if args.screenpipe:
181
+ return import_screenpipe(con, args.screenpipe, dry_run=args.dry_run)
182
+ if args.csv:
183
+ return import_csv(con, args.csv, dry_run=args.dry_run)
184
+ return import_json(con, args.json, dry_run=args.dry_run)
185
+ except RefusedError as e:
186
+ print(f"error: {e}", file=sys.stderr)
187
+ return 1
188
+
189
+
190
+ def cmd_retag(args: argparse.Namespace) -> int:
191
+ from datetime import datetime as _dt
192
+ db = _resolve_db(args.db) if args.db else default_db_path()
193
+ if not Path(db).exists():
194
+ print(f"no database at {db} — run `pctx capture` first", file=sys.stderr)
195
+ return 1
196
+ con = connect(db)
197
+ init_db(con)
198
+ q = ("SELECT id, app, title, url FROM events"
199
+ " WHERE (tags IS NULL OR tags = '')")
200
+ params: list = []
201
+ if args.since:
202
+ try:
203
+ since_ts = _dt.strptime(args.since, "%Y-%m-%d").astimezone().timestamp()
204
+ except ValueError:
205
+ print("--since must be YYYY-MM-DD", file=sys.stderr)
206
+ return 2
207
+ q += " AND ts >= ?"
208
+ params.append(since_ts)
209
+ rows = con.execute(q, params).fetchall()
210
+ tagged = 0
211
+ for r in rows:
212
+ found = extract_tags(r["app"], r["title"], r["url"])
213
+ tag_str = ",".join(found) if found else None
214
+ con.execute("UPDATE events SET tags = ? WHERE id = ?", (tag_str, r["id"]))
215
+ if tag_str:
216
+ tagged += 1
217
+ con.commit()
218
+ print(f"[privatecontext] retagged {tagged} of {len(rows)} scanned events")
219
+ return 0
220
+
221
+
222
+ def main(argv: list[str] | None = None) -> int:
223
+ args = build_parser().parse_args(argv)
224
+ return args.func(args)
225
+
226
+
227
+ if __name__ == "__main__":
228
+ raise SystemExit(main())